From 44260a38308ce2868c9b6b7030844ed751fd38dc Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 19:39:37 +0800 Subject: [PATCH 001/294] Keep narrow workspaces usable and remember GUI preferences --- CHANGELOG.md | 12 ++ Progress.md | 12 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- Third_Party_License.md | 1 - architecture.md | 2 +- architecture_explore.md | 20 +-- dev.toml | 2 +- dev_requirements.txt | 1 - .../doc/new_features/v223_features_doc.rst | 31 ++-- .../Zh/doc/new_features/v223_features_doc.rst | 29 ++-- docs/source/getting_started/installation.rst | 2 - docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- .../gui/language_wrapper/english.py | 1 + .../gui/language_wrapper/japanese.py | 1 + .../language_wrapper/simplified_chinese.py | 1 + .../language_wrapper/traditional_chinese.py | 1 + je_auto_control/gui/main_widget.py | 6 +- je_auto_control/gui/main_window.py | 42 ++++- je_auto_control/gui/workspace.py | 121 ++++++++++++++ je_auto_control/gui/workspace_settings.py | 42 +++++ pyproject.toml | 4 +- requirements.txt | 1 - .../headless/test_gui_workspace_layout.py | 153 ++++++++++++++++++ uv.lock | 113 +------------ 27 files changed, 434 insertions(+), 181 deletions(-) create mode 100644 je_auto_control/gui/workspace.py create mode 100644 je_auto_control/gui/workspace_settings.py create mode 100644 test/unit_test/headless/test_gui_workspace_layout.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d32a82885..2644d8fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +### GUI workspace + +- Tab contents scroll when the window is smaller than their minimum layout; + the embedded workspace's page lookup and show/hide APIs keep returning the + original widgets. Close buttons are painted as vectors in the active palette. +- Theme, text size, navigation visibility and width persist on window close in + `~/.je_auto_control/gui.ini`. Remove this file to reset workspace preferences. + `Ctrl+B` now toggles the fixed navigation panel. +- The `gui` extra and requirements files no longer install `qt-material` or + its otherwise unused transitive dependencies. Applications importing + `qt_material` themselves must declare their own dependency. + ### Added - Action files can be signed with an Ed25519 key pair, so an endpoint that diff --git a/Progress.md b/Progress.md index dd14e3088..187aa55f8 100644 --- a/Progress.md +++ b/Progress.md @@ -14,15 +14,9 @@ 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 計畫 B(動作日誌、自愈量測、codegen)、C(持久化同步與遠端 session;其中 C4 的擁有者模型已做)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: -- **窄視窗的內容是被擠壓而不是可捲動**:`gui/main_widget.py` 給 `QTabWidget` 明確的最小尺寸讓視窗能縮到 640×420, - 但分頁內容沒有包進 `QScrollArea`;包進去會改變 `tabs.indexOf(entry.widget)` 這個 PyBreeze 與測試都在用的關係,要一起設計。 -- **主題與面板狀態不會記住**:`AutoControlGUIUI.set_theme`、導覽面板的顯示與寬度、字級都只活在當次執行; - 要用 `QSettings` 存,並讓測試不寫到使用者的設定。 -- **`qt-material` 還在 `[gui]` extra**:`gui/main_window.py` 已不匯入它。移除要同時改 `pyproject.toml`、`dev.toml`、 - `requirements.txt`、`uv.lock` 與 mypy 的 override,並先確認 PyBreeze 沒有靠這個 extra 取得它。 -- **分頁的關閉鈕是 Fusion 內建圖示**:計畫規定主題不新增點陣圖相依,換圖示要用 Qt 內建向量或既有資產。 - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的 `TabRegistry.open/close` 介面與 `close` 釋放訂閱**:現在關閉分頁只是從分頁列移除,widget 留著。 +- **F2 其餘**:計畫指定的 `WorkspaceShell` 介面、右側可收合詳情,以及各面板的空白/錯誤/permission states 還沒全面交付。 - **F3**(共用 worker、取消、關閉時不碰已銷毀物件、`webrtc_panel.py` 拆分)與 **F4**(啟動/記憶體基準、mixed-DPI、 功能對等測試)尚未開始。 @@ -120,7 +114,7 @@ sys_platform != 'win32' or platform_machine != 'ARM64' | `cryptography>=50.0.0` | **沒有** | wheel 只出到 **46.0.3**,46.0.4 起上游就不再發 win_arm64。`>=50.0.0` 是**安全下限**(GHSA-537c-gmf6-5ccf 與 GHSA-g6cj-pr64-35w5),不能為了 arm64 降回去。 | | `pillow==12.3.0` | 有 | `pillow-12.3.0-cp3xx-win_arm64.whl` 一直都在。**曾經被寫成卡點,那是猜的,它從來不是。** | | `mss`/`defusedxml` | 有 | 純 Python。這三個加上 Pillow 就是 arm64 實際裝到的全部。 | -| `PySide6==6.11.1`/`qt-material==2.17` | 有 | `[gui]` extra 在 arm64 上裝得起來。 | +| `PySide6==6.11.1` | 有 | `[gui]` extra 在 arm64 上裝得起來。 | | `aiortc` | **沒有** | 卡在傳遞相依 `google-crc32c`,與本專案的選擇無關;`av` 自己有 wheel。 | 重驗指令(不需要 arm64 機器,也不需要 runner): @@ -259,7 +253,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **`pytest --cov` 沒有重新量過**:進入點搬到 `je_auto_control_pytest` 之後,它是否還少算,未知;規則仍是 `coverage run -m pytest`。 - **快到 750 行上限的檔案**:`utils/usb/passthrough/viewer_client.py`(748)、`gui/remote_desktop/connection_screen.py`(729)、 `utils/mcp_server/server.py`(734)、`utils/mcp_server/http_transport.py`(727)、`utils/agent/backends/anthropic_computer_use.py`(723)——下一次要加東西就得先拆。 -- **Sphinx 沒有建置過**:這批改了 35 個 `.rst`,都沒有渲染檢查。 +- **Sphinx 嚴格建置未通過**:`docs/source` 的英/中文文件仍有標題線過短等既有警告;需修完後以 `-W --keep-going` 重建兩種語言並檢查渲染。 --- diff --git a/README.md b/README.md index f062d3d07..b8a8f037b 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Optional extras, installed only when you need them: | Extra | Enables | |---|---| -| `gui` | PySide6 desktop application (48 tabs): a searchable navigation panel (`Ctrl+K`) lists every feature by category, tabs are built the first time they are opened, and **View → Theme** switches dark / light | +| `gui` | PySide6 desktop application (48 tabs): a searchable navigation panel (`Ctrl+K`) lists every feature by category, tabs are built the first time they are opened, and **View → Theme** switches dark / light. Pages scroll in narrow windows; theme, text size, navigation visibility (`Ctrl+B`) and width are saved in `~/.je_auto_control/gui.ini` on close. Themes use Qt directly; `qt-material` is not required. | | `webrtc` | WebRTC remote desktop, USB passthrough (`aiortc`, `av`) | | `signaling` | Standalone signaling / rendezvous server (`fastapi`, `uvicorn`) | | `discovery` | mDNS / Zeroconf LAN host discovery | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index a5380bab6..f5f624407 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -115,7 +115,7 @@ pip install je_auto_control[gui] # 加上 PySide6 桌面应用 | Extra | 启用的功能 | |---|---| -| `gui` | PySide6 桌面应用(48 个标签页):左侧可搜索的导航面板(`Ctrl+K`)按分类列出全部功能,标签页在第一次打开时才创建,**View → Theme** 切换深色/浅色 | +| `gui` | PySide6 桌面应用(48 个标签页):左侧可搜索的导航面板(`Ctrl+K`)按分类列出全部功能,标签页在第一次打开时才创建,**View → Theme** 切换深色/浅色。窄窗口的内容可滚动;关闭窗口时,主题、字号、导航面板显示(`Ctrl+B`)与宽度会保存到 `~/.je_auto_control/gui.ini`。主题直接使用 Qt,不需要 `qt-material`。 | | `webrtc` | WebRTC 远程桌面、USB 直通(`aiortc`、`av`) | | `signaling` | 独立的信令/rendezvous 服务器(`fastapi`、`uvicorn`) | | `discovery` | mDNS / Zeroconf 局域网主机发现 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 5519d8c4b..dbfb4c4cb 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -114,7 +114,7 @@ pip install je_auto_control[gui] # 加上 PySide6 桌面應用程式 | Extra | 啟用的功能 | |---|---| -| `gui` | PySide6 桌面應用程式(48 個分頁):左側可搜尋的導覽面板(`Ctrl+K`)依分類列出全部功能,分頁在第一次開啟時才建立,**View → Theme** 切換深色/淺色 | +| `gui` | PySide6 桌面應用程式(48 個分頁):左側可搜尋的導覽面板(`Ctrl+K`)依分類列出全部功能,分頁在第一次開啟時才建立,**View → Theme** 切換深色/淺色。窄視窗的內容可捲動;關閉視窗時,主題、字級、導覽面板顯示(`Ctrl+B`)與寬度會存入 `~/.je_auto_control/gui.ini`。主題直接使用 Qt,不需要 `qt-material`。 | | `webrtc` | WebRTC 遠端桌面、USB 直通(`aiortc`、`av`) | | `signaling` | 獨立的訊令/rendezvous 伺服器(`fastapi`、`uvicorn`) | | `discovery` | mDNS / Zeroconf 區網主機探索 | diff --git a/Third_Party_License.md b/Third_Party_License.md index 06c4b5d7a..03d1cfee0 100644 --- a/Third_Party_License.md +++ b/Third_Party_License.md @@ -22,7 +22,6 @@ Full copies of the upstream license texts are archived under `LICENSEs/`. | Package | Version | License | Purpose | |---|---|---|---| | [PySide6](https://pypi.org/project/PySide6/) | 6.11.1 | LGPL-3.0 / Qt Commercial | Qt 6 GUI framework used by `start_autocontrol_gui()` | -| [qt-material](https://pypi.org/project/qt-material/) | 2.17 | BSD-2-Clause | Material Design themes for PySide6 | ### Optional feature dependencies (loaded lazily, not pinned) diff --git a/architecture.md b/architecture.md index d5e112d30..312016d2c 100644 --- a/architecture.md +++ b/architecture.md @@ -29,7 +29,7 @@ entry points → execution core (`utils/executor/`) → headless capabilities (` | `je_auto_control/wrapper/` | Platform-neutral API (`auto_control_mouse/keyboard/screen/image/record/window.py`); `platform_wrapper.py` picks the backend; `backend_contract.py` types the seam; `window_backends/`. | | `je_auto_control/{windows,osx,linux_with_x11,linux_wayland}/` | Desktop OS backends; only the running OS's backend is imported. | | `je_auto_control/{android,ios}/` | Mobile device control (adb / uiautomator2, WebDriverAgent). | -| `je_auto_control/gui/` | Optional PySide6 GUI (`[gui]` extra): `main_window.py` (menus, navigation dock, theme), `main_widget.py` (the tabbed workspace), the tab table `tab_registry.py` (tabs are built on first open), `navigation.py`, `theme.py`, `script_builder/`, `remote_desktop/`, `language_wrapper/`. | +| `je_auto_control/gui/` | Optional PySide6 GUI (`[gui]` extra): `main_window.py` (menus, navigation dock, theme), `main_widget.py` (the tabbed workspace), `workspace.py` (scroll containers preserving original page identity, vector close buttons), `workspace_settings.py` (validated per-user QSettings, injectable in tests), the tab table `tab_registry.py` (tabs are built on first open), `navigation.py`, `theme.py`, `script_builder/`, `remote_desktop/`, `language_wrapper/`. | | `autocontrol-lsp/` | Separate distribution: language server for `AC_*` action JSON, plus a `vscode/` client. | | `test/` | `unit_test/headless/` (CI gate), `unit_test/flow_control/`, `integrated_test/`, `gui_test/`, `manual_test/`, `verify/`. | | `docs/` | Sphinx docs, `API_LIFECYCLE.md`, `CAPABILITY_MATRIX.md`. | diff --git a/architecture_explore.md b/architecture_explore.md index e4f1efc4a..ed5924a3a 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -6,7 +6,7 @@ > 擷取每個模組的 docstring 與頂層公開名稱;統計數字取自實際檔案,非估算。 > 指令數與公開 API 數以 `executor.known_commands()` 與 `je_auto_control.__all__` 在工作樹上實測取得。 > -> **掃描時間**:2026-10-08 **版本**:`pyproject.toml` version `0.0.225` **分支**:`feat/coverage-to-80` +> **掃描時間**:2026-10-18 **版本**:`pyproject.toml` version `0.0.228` **分支**:`dev` --- @@ -19,8 +19,8 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,077 | -| 程式碼總行數 | 161,551 | +| Python 模組總數(含周邊子專案) | 1,079 | +| 程式碼總行數 | 161,748 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -873,7 +873,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.5 GUI 層(`gui/`,84 檔/26,367 行) -GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-material),且刻意保持「薄」: +GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6),且刻意保持「薄」: 每個分頁只把使用者輸入翻譯成對 `utils/` 無頭核心的呼叫。 #### 骨架 @@ -881,11 +881,13 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate | 模組 | 行數 | 職責 | | --- | ---: | --- | | `gui/__init__.py` | 25 | `start_autocontrol_gui()`:**唯一**會延遲匯入 PySide6 的地方,維持頂層套件 Qt-free。 | -| `main_window.py` | 379 | `QMainWindow`:選單列(File/Actions/View/…)、左側導覽面板 dock(`Ctrl+K` 搜尋、`Ctrl+B` 收合)、即時語言切換、字級預設、深色/淺色主題(`theme.py` 的 token,不再用 qt-material)。分頁分為 core/editing/detection/automation/system 五類。 | -| `main_widget.py` | 368 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | +| `main_window.py` | 407 | `QMainWindow`:選單列(File/Actions/View/…)、左側導覽面板 dock(`Ctrl+K` 搜尋、`Ctrl+B` 收合)、即時語言切換、字級預設、深色/淺色主題(`theme.py` 的 token,不再用 qt-material)。分頁分為 core/editing/detection/automation/system 五類。 | +| `main_widget.py` | 370 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | | `tab_registry.py` | 125 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。不匯入 Qt。 | | `navigation.py` | 200 | `NavigationPanel`:搜尋框 + 依分類的功能樹,列出每個已註冊分頁(開啟中的以粗體標示),只回報被選的鍵,開啟分頁仍由視窗負責。 | | `theme.py` | 178 | 設計 token(`ThemeTokens`:顏色、圓角、間距、字族)、深色與淺色兩組值、由 token 產生的樣式表與對應的 `QPalette`;不載入圖檔或字型檔。 | +| `workspace.py` | 121 | 可捲動的分頁容器:保留 `widget/currentWidget/indexOf/setCurrentWidget` 的原始頁面身分、移除時保留頁面所有權,並直接繪製隨 palette 變色的向量關閉鈕。 | +| `workspace_settings.py` | 42 | 驗證與載入主題、字級、導覽面板顯示與寬度;QSettings 使用當次帳號家目錄的 `gui.ini`,測試可注入獨立檔案。 | | `_auto_click_tab.py` | 291 | 自動點擊分頁的 mixin 建構器。 | | `_screenshot_tab.py` | 137 | 截圖/取像素分頁 mixin。 | | `_image_detect_tab.py` | 115 | 影像偵測分頁 mixin。 | @@ -897,7 +899,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate | `_screen_geometry.py` | 55 | Qt 邏輯座標與截圖用的原生像素互轉:`native_region()`、`screen_at_native()`、`logical_point()`(每個螢幕的左上角在兩者相同,螢幕內依 device pixel ratio 縮放)。區域選取與主機端標註覆蓋層都用它。 | | `_daemon_thread.py` | 79 | `DaemonThread`:`QThread` 的替代品,保留遠端桌面 worker 用到的介面(`start`/`run`/`isRunning`/`wait`/`requestInterruption`/`started`/`finished`),但 `run()` 跑在 daemon `threading.Thread` 上,刪除物件或程式結束都不會銷毀執行中的執行緒。 | | `_worker_thread.py` | 216 | `start_worker()`:在 daemon `threading.Thread` 上執行 `QObject` worker 的 `run()`(沒有 `QThread` 可被銷毀),並經由分頁擁有的中繼物件回報結果(回呼一律在 GUI 執行緒;worker 沒處理的例外也送到 `on_fail`);worker 留在模組登錄表直到 GUI 執行緒看到它結束,回傳 `WorkerHandle`(`isRunning()`);程式結束時先呼叫 worker 的 `request_stop()`,最多等 10 秒,仍在跑的隨行程結束。 | -| `language_wrapper/` | 5,067 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | +| `language_wrapper/` | 5,071 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | | `selector/` | 216 | 拖曳選取螢幕區域的半透明全螢幕覆蓋層與樣板裁切工具(互動式,但都有對應的程式化 API)。 | > **分頁指令一律走 Actions 選單**:分頁本身只放輸入、表格與結果檢視,指令由視窗層選單暴露。 @@ -1079,7 +1081,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 98 | 28,507 | +| `gui/` | 100 | 28,704 | | `utils/mcp_server/` | 37 | 19,161 | | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,666 | @@ -1100,5 +1102,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 852 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 689 | 58,556 | -| **總計** | **1,071** | **161,486** | +| **總計** | **1,073** | **161,683** | diff --git a/dev.toml b/dev.toml index df0a3e872..e034a7e4e 100644 --- a/dev.toml +++ b/dev.toml @@ -78,7 +78,7 @@ find = { namespaces = false } ] [project.optional-dependencies] -gui = ["PySide6==6.11.2", "qt-material==2.17"] +gui = ["PySide6==6.11.2"] webrtc = ["aiortc>=1.14.0", "av>=14.0.0"] signaling = ["fastapi>=0.115", "starlette>=1.0.1", "uvicorn>=0.32"] discovery = ["zeroconf>=0.149.16"] diff --git a/dev_requirements.txt b/dev_requirements.txt index bb181fe0a..95d89b516 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -5,7 +5,6 @@ twine sphinx sphinx-rtd-theme PySide6==6.11.2 -qt-material==2.17 mss==10.2.0 defusedxml==0.7.1 diff --git a/docs/source/Eng/doc/new_features/v223_features_doc.rst b/docs/source/Eng/doc/new_features/v223_features_doc.rst index 8b64c4fc4..c6a18f6fe 100644 --- a/docs/source/Eng/doc/new_features/v223_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v223_features_doc.rst @@ -40,21 +40,6 @@ A tab is built the first time it is opened: the window starts with the three default tabs and the forms the main widget owns, and imports the module of any other tab only when you open it. -The navigation panel --------------------- - -Every registered tab is listed on the left of the window, grouped by the same -five categories, whether it is open or not; open tabs are shown in bold. -Click a feature to open it (or bring it to the front). The search box filters -the list as you type — by title, by key (``usb_devices``) or by category — and -**Return** opens the first match. ``Ctrl+K`` (**View → Search Features...**) -puts the cursor in the search box from anywhere, and ``Ctrl+B`` -(**View → Navigation Panel**) hides or shows the panel. - -A tab is built the first time it is opened: the window starts with the three -default tabs and the forms the main widget owns, and imports the module of -any other tab only when you open it. - The View menu ------------- @@ -69,6 +54,22 @@ The View menu * **View → Text Size** offers auto (screen-height based) and preset font sizes applied live, on top of the active theme. +Workspace size and preferences +------------------------------ + +Pages scroll horizontally and vertically when their minimum layout exceeds the +window. The tab registry and embedded ``tabs.widget/indexOf/currentWidget`` +APIs continue to use the original page, including after hiding and reopening. +Close buttons are vectors painted in the current palette without image assets. + +On window close, theme, text size, navigation visibility and width are saved +through QSettings to ``~/.je_auto_control/gui.ini``. Delete that file to reset +preferences. Embedders and tests can pass ``settings=QSettings(...)`` to +``AutoControlGUIUI`` to select a separate settings file. The home path is +resolved at construction time; the test suite redirects it to its scratch home. +The ``gui`` extra requires only PySide6; applications using ``qt_material`` +directly must declare that dependency themselves. + The contract test ----------------- diff --git a/docs/source/Zh/doc/new_features/v223_features_doc.rst b/docs/source/Zh/doc/new_features/v223_features_doc.rst index 6f0e01764..0f4f9d7c3 100644 --- a/docs/source/Zh/doc/new_features/v223_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v223_features_doc.rst @@ -28,20 +28,8 @@ Actions 選單 視窗左側依同樣五個分類列出每一個已註冊的分頁,不論是否已開啟;已開啟的以粗體 顯示。點一下就開啟該功能(已開啟則切到最前面)。搜尋框會隨輸入過濾清單——比對 標題、鍵名(``usb_devices``)或分類——按 **Return** 開啟第一個符合的項目。 -``Ctrl+K``(**View → Search Features...**)從任何地方把游標移到搜尋框, -``Ctrl+B``(**View → Navigation Panel**)隱藏或顯示面板。 - -分頁在第一次開啟時才建立:視窗啟動時只有三個預設分頁與主元件自己的表單,其他 -分頁的模組要到開啟時才匯入。 - -導覽面板 --------- - -視窗左側依同樣五個分類列出每一個已註冊的分頁,不論是否已開啟;已開啟的以粗體 -顯示。點一下就開啟該功能(已開啟則切到最前面)。搜尋框會隨輸入過濾清單——比對 -標題、鍵名(``usb_devices``)或分類——按 **Return** 開啟第一個符合的項目。 -``Ctrl+K``(**View → Search Features...**)從任何地方把游標移到搜尋框, -``Ctrl+B``(**View → Navigation Panel**)隱藏或顯示面板。 +``Ctrl+K`` (**View → Search Features...**) 從任何地方把游標移到搜尋框, +``Ctrl+B`` (**View → Navigation Panel**) 隱藏或顯示面板。 分頁在第一次開啟時才建立:視窗啟動時只有三個預設分頁與主元件自己的表單,其他 分頁的模組要到開啟時才匯入。 @@ -57,6 +45,19 @@ View 選單 設計 token;視窗不再使用 ``qt-material``。 * **View → Text Size** 提供自動(依螢幕高度)與預設字級,即時套用在目前的主題上。 +工作區尺寸與設定 +---------------- + +內容的最小版面超過視窗時,分頁可水平與垂直捲動。分頁註冊表與嵌入使用的 +``tabs.widget/indexOf/currentWidget`` API 仍使用原始頁面,包含隱藏後重新開啟。 +關閉鈕直接以目前的 palette 繪製向量,不載入圖檔。 + +關閉視窗時,主題、字級、導覽面板的顯示與寬度透過 QSettings 保存到 +``~/.je_auto_control/gui.ini``。刪除此檔可重設設定。嵌入程式與測試可傳入 +``AutoControlGUIUI(settings=QSettings(...))`` 指定獨立設定檔。家目錄在建立視窗時 +解析,測試套件會將它導向暫存家目錄。``gui`` extra 只需要 PySide6; +直接使用 ``qt_material`` 的應用程式應自行宣告該相依。 + 契約測試 -------- diff --git a/docs/source/getting_started/installation.rst b/docs/source/getting_started/installation.rst index 0bfb61d74..a853e2b9b 100644 --- a/docs/source/getting_started/installation.rst +++ b/docs/source/getting_started/installation.rst @@ -70,8 +70,6 @@ Dependencies - Linux X11 backend (auto-installed on Linux) * - ``PySide6`` - GUI application (optional, install with ``[gui]``) - * - ``qt-material`` - - GUI theme (optional, install with ``[gui]``) Platform Support ================ diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index e2f1d8e36..11f9df573 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -302,3 +302,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **What**: `PackageManager.allow_arbitrary_packages` defaults to `False`; `JE_AUTOCONTROL_ALLOWED_PACKAGES` is read where the package manager is created, so every entry point honours it; `je_auto_control run --allow-package`. - **Tests**: `test_package_gate.py` (12). - **Open items**: none beyond the pre-existing same-list limitation (`Progress.md`). + +## U-20261018-01 · 2026-10-18 · GUI pages scroll and workspace preferences persist without qt-material · #done #gui #F2 + +- **What**: `ScrollableTabs` wraps content in a resizable scroll area while `widget`, `currentWidget`, `indexOf` and `setCurrentWidget` still refer to the registered page. Hidden pages remain owned and can reopen. Close buttons paint a palette-aware vector cross, have keyboard focus and translated accessible labels. +- **Preferences**: `AutoControlGUIUI(settings=...)` accepts an isolated QSettings instance; the default resolves `~/.je_auto_control/gui.ini` at construction. Closing saves validated theme, font preset, navigation visibility and width. A hidden restored dock retains its last laid-out width across successive closes. `Ctrl+B` works with the fixed dock's toggle action enabled. +- **Dependencies**: Removed `qt-material` from both manifests, both requirements files, the mypy override, licence list and generated lock (uv 0.13.1); the otherwise unused Jinja2/MarkupSafe dependencies also disappear. PyBreeze gets its own `qt-material` through JEDITOR's direct dependency, independently of AutoControl's extra. +- **Evidence**: 169 related GUI/contract regressions and 47 documentation, complexity, size, manifest and workspace gates passed. Jeffrey_RPA’s own environment passed all 740 `test_gui_facade.py` / `test_gui_control.py` consumer tests against this worktree (five dependency deprecation warnings). Three-target mypy reported zero failing modules; ruff and bandit passed. Wheel and sdist built successfully; wheel metadata requires only PySide6 for the GUI extra and includes both new workspace modules. The 640×420 offscreen Script Builder rendered in dark and light with the original page identity and a working horizontal scrollbar. Pylint was inspected (native PySide6 symbol inference and existing Qt naming/style findings); radon reported no C-or-higher functions in the changed shell modules. +- **Open items**: Remaining F1–F4 work and strict Sphinx warnings are recorded in `Progress.md`; this is not completion of the full GUI plan. diff --git a/docs/updates/README.md b/docs/updates/README.md index e6dbf4fcf..4cbdac39a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-01 | 2026-10-18 | GUI pages scroll and workspace preferences persist without qt-material | #done #gui #F2 | [2026-10](2026-10.md) | | U-20261009-14 | 2026-10-09 | The package gate refuses unlisted packages by default | #done #security #breaking | [2026-10](2026-10.md) | | U-20261009-13 | 2026-10-09 | REST routes and MCP tools are authorised by role when a user store is configured | #done #security #rbac #A10 | [2026-10](2026-10.md) | | U-20261009-12 | 2026-10-09 | Remote-desktop hosts and viewers have owners | #done #remote-desktop #gui #C4 | [2026-10](2026-10.md) | @@ -363,7 +364,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 27 | +| [2026-10.md](2026-10.md) | 2026-10 | 28 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 6bc8d1ebe..d376aadae 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -1338,4 +1338,5 @@ "menu_actions_none": "(No actions on this tab)", "menu_choose_script_dir": "Choose Script Directory...", "execute_editor_script": "Run Editor Content", + "close_tab": "Close tab", } diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 0740bfc51..33cf567d4 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -1225,4 +1225,5 @@ "menu_actions_none": "(このタブにアクションはありません)", "menu_choose_script_dir": "スクリプトフォルダーを選択...", "execute_editor_script": "エディター内容を実行", + "close_tab": "タブを閉じる", } diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index d002ab239..fcb15748f 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -1204,4 +1204,5 @@ "menu_actions_none": "(此标签页没有操作)", "menu_choose_script_dir": "选择脚本目录...", "execute_editor_script": "运行编辑器内容", + "close_tab": "关闭标签页", } diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index 046af99cd..0c3878ede 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -1205,4 +1205,5 @@ "menu_actions_none": "(此分頁沒有操作)", "menu_choose_script_dir": "選擇腳本資料夾...", "execute_editor_script": "執行編輯器內容", + "close_tab": "關閉分頁", } diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index 4ee4e4ac0..9770fdaf1 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -5,7 +5,7 @@ from PySide6.QtCore import QTimer, Signal, QObject from PySide6.QtGui import QKeyEvent, Qt from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, QTabWidget, + QWidget, QVBoxLayout, QLabel, ) from je_auto_control.gui._auto_click_tab import AutoClickTabMixin @@ -19,6 +19,7 @@ from je_auto_control.gui.tab_registry import ( TAB_SPECS, MenuActions, TabEntry, WidgetFactory, lazy_factory, ) +from je_auto_control.gui.workspace import ScrollableTabs from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_action_json @@ -52,7 +53,7 @@ def __init__(self, parent=None): self._tab_entries: List[TabEntry] = [] - self.tabs = QTabWidget() + self.tabs = ScrollableTabs() self.tabs.setTabsClosable(True) self.tabs.setDocumentMode(True) self.tabs.setUsesScrollButtons(True) @@ -319,6 +320,7 @@ def _translate(self, key: str) -> str: def retranslate(self) -> None: """Relabel tab titles and propagate into every child tab.""" + self.tabs.retranslate() for entry in self._built_entries(): index = self.tabs.indexOf(entry.widget) if index != -1: diff --git a/je_auto_control/gui/main_window.py b/je_auto_control/gui/main_window.py index a8ef06859..2757e51bd 100644 --- a/je_auto_control/gui/main_window.py +++ b/je_auto_control/gui/main_window.py @@ -1,8 +1,8 @@ """Top-level window: menu bar, feature navigation, tabbed workspace, themes, live language switching.""" import sys -from PySide6.QtCore import Qt -from PySide6.QtGui import QAction, QActionGroup, QKeySequence +from PySide6.QtCore import QEvent, QObject, QSettings, Qt +from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QKeySequence from PySide6.QtWidgets import ( QApplication, QDockWidget, QFileDialog, QMainWindow, QMenu, QMessageBox, QWidget, ) @@ -13,8 +13,9 @@ from je_auto_control.gui.main_widget import AutoControlGUIWidget from je_auto_control.gui.navigation import NavigationPanel from je_auto_control.gui.theme import ( - DEFAULT_THEME, THEMES, apply_theme, font_rule, prepare_application, theme_named, + THEMES, apply_theme, font_rule, prepare_application, theme_named, ) +from je_auto_control.gui.workspace_settings import WorkspacePreferences, default_settings def _t(key: str, default: str = "") -> str: @@ -48,15 +49,18 @@ def _t(key: str, default: str = "") -> str: class AutoControlGUIUI(QMainWindow): """Main window: menu bar, navigation panel and AutoControlGUIWidget (which owns the tabs).""" - def __init__(self) -> None: + def __init__(self, *, settings: QSettings | None = None) -> None: super().__init__() + self._settings = settings if settings is not None else default_settings() + preferences = WorkspacePreferences.load(self._settings) + self._navigation_width = preferences.navigation_width self.app_id = _t("application_name", "AutoControlGUI") if sys.platform in ["win32", "cygwin", "msys"]: from ctypes import windll # type: ignore[attr-defined] # reason: win32-only ctypes windll.shell32.SetCurrentProcessExplicitAppUserModelID(self.app_id) - self._user_font_pt: int = 0 # 0 means auto-detect from screen - self._theme_name: str = DEFAULT_THEME + self._user_font_pt: int = preferences.font_pt # 0 means auto-detect from screen + self._theme_name: str = preferences.theme # The theme's style sheet is kept so _apply_font_pt can append the # font rule instead of replacing (and thereby wiping) the theme. self._theme_stylesheet: str = apply_theme(self, theme_named(self._theme_name)) @@ -68,6 +72,9 @@ def __init__(self) -> None: self.auto_control_gui_widget = AutoControlGUIWidget(parent=self) self.setCentralWidget(self.auto_control_gui_widget) self._build_navigation() + self.resizeDocks([self._navigation_dock], [preferences.navigation_width], Qt.Orientation.Horizontal) + self._navigation_dock.setVisible(preferences.navigation_visible) + self._navigation_dock.installEventFilter(self) self._view_menu: QMenu = None self._actions_menu: QMenu = None @@ -85,6 +92,25 @@ def __init__(self) -> None: listener = self._on_language_changed self.destroyed.connect(lambda *_args: language_wrapper.remove_listener(listener)) + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 # reason: Qt override + """Save workspace preferences when this window closes.""" + super().closeEvent(event) + if event.isAccepted(): + for key, value in ( + ("theme", self._theme_name), ("font_pt", self._user_font_pt), + ("navigation_visible", not self._navigation_dock.isHidden()), + ("navigation_width", self._navigation_width), + ): + self._settings.setValue(f"workspace/{key}", value) + self._settings.sync() + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 # reason: Qt override + """Remember laid-out dock widths, keeping the saved width while hidden.""" + if watched is self._navigation_dock and event.type() == QEvent.Type.Resize: + if not self._navigation_dock.isHidden(): + self._navigation_width = self._navigation_dock.width() + return super().eventFilter(watched, event) + # --- navigation ---------------------------------------------------------- def _build_navigation(self) -> None: @@ -93,7 +119,9 @@ def _build_navigation(self) -> None: self.navigation.feature_activated.connect(self.auto_control_gui_widget.activate_tab) self._navigation_dock = QDockWidget(self) self._navigation_dock.setObjectName("NavigationDock") - self._navigation_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) + # Closable enables Qt's toggleViewAction (including Ctrl+B). The empty + # title bar still keeps the navigation panel fixed and button-free. + self._navigation_dock.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) self._navigation_dock.setTitleBarWidget(QWidget(self._navigation_dock)) self._navigation_dock.setWidget(self.navigation) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._navigation_dock) diff --git a/je_auto_control/gui/workspace.py b/je_auto_control/gui/workspace.py new file mode 100644 index 000000000..e92d77214 --- /dev/null +++ b/je_auto_control/gui/workspace.py @@ -0,0 +1,121 @@ +"""Scrollable tab pages that preserve the embedded workspace's widget API.""" +from typing import Any + +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import QPaintEvent, QPainter, QPen +from PySide6.QtWidgets import QAbstractButton, QScrollArea, QTabBar, QTabWidget, QWidget + +from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper + + +class _CloseButton(QAbstractButton): + """Paint a palette-aware vector cross without loading an image asset.""" + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setFixedSize(20, 20) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.retranslate() + + def retranslate(self) -> None: + """Refresh the accessible label and tooltip in the active language.""" + self.setAccessibleName(language_wrapper.translate("close_tab", "Close tab")) + self.setToolTip(self.accessibleName()) + + def paintEvent(self, _event: QPaintEvent) -> None: # noqa: N802 # reason: Qt override + """Draw the close cross and keyboard focus indicator.""" + painter = QPainter(self) + try: + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + colour = self.palette().highlight().color() if self.underMouse() else self.palette().text().color() + painter.setPen(QPen(colour, 1.6)) + painter.drawLine(QPointF(6, 6), QPointF(14, 14)) + painter.drawLine(QPointF(14, 6), QPointF(6, 14)) + if self.hasFocus(): + painter.drawRoundedRect(2, 2, 16, 16, 3, 3) + finally: + painter.end() + + +class ScrollableTabs(QTabWidget): + """Wrap pages in scroll areas while returning original pages to callers. + + ``widget``, ``currentWidget``, ``indexOf`` and ``setCurrentWidget`` retain + the original page identity used by embedders and the tab registry. + Removing a page keeps it owned by this widget for subsequent reopening. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTabsClosable(True) + + def scroll_area(self, index: int) -> QScrollArea | None: + """Return the scroll container for a page, or None for an invalid index.""" + container = super().widget(index) + return container if isinstance(container, QScrollArea) else None + + def widget(self, index: int) -> QWidget | None: + """Return the original page at index.""" + container = self.scroll_area(index) + return container.widget() if container is not None else None + + def currentWidget(self) -> QWidget | None: # noqa: N802 # reason: Qt override + """Return the original active page.""" + return self.widget(self.currentIndex()) + + def indexOf(self, widget: QWidget) -> int: # noqa: N802 # reason: Qt override + """Find an original page without exposing its scroll container.""" + return next((index for index in range(self.count()) if self.widget(index) is widget), -1) + + def setCurrentWidget(self, widget: QWidget) -> None: # noqa: N802 # reason: Qt override + """Select an original page when it belongs to this tab widget.""" + index = self.indexOf(widget) + if index >= 0: + self.setCurrentIndex(index) + + def addTab(self, widget: QWidget, *args: Any) -> int: # noqa: N802 # reason: Qt overloads + """Append a page, accepting Qt's title and optional icon arguments.""" + return self.insertTab(self.count(), widget, *args) + + def insertTab(self, index: int, widget: QWidget, *args: Any) -> int: # noqa: N802 # reason: Qt overloads + """Insert a scrollable page and a vector close button.""" + container = QScrollArea(self) + container.setWidgetResizable(True) + container.setFrameShape(QScrollArea.Shape.NoFrame) + container.setWidget(widget) + result = super().insertTab(index, container, *args) + tab_bar = self.tabBar() + for side in (QTabBar.ButtonPosition.LeftSide, QTabBar.ButtonPosition.RightSide): + old_button = tab_bar.tabButton(result, side) + if old_button is not None: + tab_bar.setTabButton(result, side, None) + old_button.deleteLater() + button = _CloseButton(tab_bar) + button.clicked.connect(lambda: self.tabCloseRequested.emit(self.indexOf(widget))) + tab_bar.setTabButton(result, QTabBar.ButtonPosition.RightSide, button) + return result + + def removeTab(self, index: int) -> None: # noqa: N802 # reason: Qt override + """Remove the scroll container while retaining ownership of its page.""" + container = self.scroll_area(index) + if container is None: + return + page = container.takeWidget() + if page is not None: + page.setParent(self) + page.hide() + super().removeTab(index) + container.setParent(None) + container.deleteLater() + + def clear(self) -> None: + """Remove every tab while keeping its original page alive.""" + while self.count(): + self.removeTab(self.count() - 1) + + def retranslate(self) -> None: + """Refresh the labels of the visible close buttons.""" + for index in range(self.count()): + button = self.tabBar().tabButton(index, QTabBar.ButtonPosition.RightSide) + if isinstance(button, _CloseButton): + button.retranslate() diff --git a/je_auto_control/gui/workspace_settings.py b/je_auto_control/gui/workspace_settings.py new file mode 100644 index 000000000..d186e23d2 --- /dev/null +++ b/je_auto_control/gui/workspace_settings.py @@ -0,0 +1,42 @@ +"""Read validated GUI preferences from an injectable per-user QSettings file.""" +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtCore import QSettings + +from je_auto_control.gui.theme import DEFAULT_THEME, theme_named + + +def default_settings() -> QSettings: + """Resolve the account home at use time so tests can isolate all writes.""" + return QSettings(str(Path.home() / ".je_auto_control" / "gui.ini"), QSettings.Format.IniFormat) + + +def _integer(settings: QSettings, key: str, default: int, minimum: int, maximum: int) -> int: + try: + value = int(settings.value(f"workspace/{key}", default)) + except (TypeError, ValueError, OverflowError): + return default + return value if minimum <= value <= maximum else default + + +@dataclass(frozen=True) +class WorkspacePreferences: + """Preferences with safe defaults for missing or malformed stored values.""" + + theme: str = DEFAULT_THEME + font_pt: int = 0 + navigation_visible: bool = True + navigation_width: int = 260 + + @classmethod + def load(cls, settings: QSettings) -> "WorkspacePreferences": + """Load supported values, rejecting unknown sizes and corrupted data.""" + font = _integer(settings, "font_pt", 0, 0, 20) + visible = str(settings.value("workspace/navigation_visible", "true")).lower() + return cls( + theme=theme_named(str(settings.value("workspace/theme", DEFAULT_THEME))).name, + font_pt=font if font in (0, 10, 12, 14, 16, 20) else 0, + navigation_visible=visible not in ("false", "0"), + navigation_width=_integer(settings, "navigation_width", 260, 200, 1200), + ) diff --git a/pyproject.toml b/pyproject.toml index 248723735..1d3ad034e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ find = { namespaces = false } ] [project.optional-dependencies] -gui = ["PySide6==6.11.2", "qt-material==2.17"] +gui = ["PySide6==6.11.2"] webrtc = ["aiortc>=1.14.0", "av>=14.0.0"] signaling = ["fastapi>=0.115", "starlette>=1.0.1", "uvicorn>=0.32"] discovery = ["zeroconf>=0.149.16"] @@ -275,7 +275,7 @@ ignore_missing_imports = true # unchecked, which is where they already were before the scope widened. module = [ # [gui] - "PySide6", "PySide6.*", "qt_material", "qt_material.*", + "PySide6", "PySide6.*", # [webrtc] / [signaling] / [discovery] "aiortc", "aiortc.*", "av", "av.*", "fastapi", "fastapi.*", "uvicorn", "uvicorn.*", diff --git a/requirements.txt b/requirements.txt index 9e9e11595..9f9a7fae8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,5 @@ je_auto_control>=0.0.217 # when je_auto_control was still unbounded here, and the floor above never # replaced that entry. A direct requirement does. pillow>=12.3.0 -qt-material==2.17 mss==10.2.0 PySide6==6.11.2 diff --git a/test/unit_test/headless/test_gui_workspace_layout.py b/test/unit_test/headless/test_gui_workspace_layout.py new file mode 100644 index 000000000..af601c156 --- /dev/null +++ b/test/unit_test/headless/test_gui_workspace_layout.py @@ -0,0 +1,153 @@ +"""Workspace overflow and preferences survive resize and a fresh window.""" +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + +@pytest.fixture(scope="module") +def qapp(): + widgets = pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) + return widgets.QApplication.instance() or widgets.QApplication([]) + + +@pytest.fixture +def window_factory(qapp, monkeypatch, tmp_path): + from PySide6.QtCore import QSettings + from je_auto_control.gui import main_widget + from je_auto_control.gui.main_window import AutoControlGUIUI + + # These real forms exercise the shell without starting native network helpers. + monkeypatch.setattr(main_widget, "TAB_SPECS", tuple( + spec for spec in main_widget.TAB_SPECS if spec.key in {"record", "variables", "auto_click"} + )) + settings = QSettings(str(tmp_path / "workspace.ini"), QSettings.Format.IniFormat) + windows = [] + + def build(): + window = AutoControlGUIUI(settings=settings) + windows.append(window) + window.show() + qapp.processEvents() + return window + + yield build, settings + for window in windows: + window.close() + window.deleteLater() + + +def test_narrow_content_scrolls_without_changing_the_tab_widget_contract(qapp): + from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + from je_auto_control.gui.workspace import ScrollableTabs + + tabs = ScrollableTabs() + page = QWidget() + layout = QVBoxLayout(page) + label = QLabel("far corner") + label.setMinimumSize(900, 650) + layout.addWidget(label) + other = QWidget() + tabs.addTab(page, "Wide") + tabs.addTab(other, "Other") + tabs.resize(360, 240) + tabs.show() + qapp.processEvents() + try: + assert tabs.indexOf(page) == 0 + assert tabs.widget(0) is page and tabs.currentWidget() is page + scroll = tabs.scroll_area(0) + assert scroll.widget() is page + assert scroll.horizontalScrollBar().maximum() > 0 + assert scroll.verticalScrollBar().maximum() > 0 + scroll.ensureVisible(page.width(), page.height(), 0, 0) + assert scroll.horizontalScrollBar().value() > 0 + assert scroll.verticalScrollBar().value() > 0 + tabs.setCurrentWidget(other) + assert tabs.currentWidget() is other + tabs.removeTab(0) + assert tabs.indexOf(page) == -1 + tabs.insertTab(0, page, "Wide again") + assert tabs.widget(0) is page and tabs.count() == 2 + assert len(tabs.findChildren(type(scroll))) == 2 + finally: + tabs.close() + tabs.deleteLater() + + +def test_theme_font_navigation_visibility_and_width_persist(window_factory, qapp): + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QMenu + + build, settings = window_factory + first = build() + first.set_theme("light") + size_action = next(action for menu in first.findChildren(QMenu) for action in menu.actions() + if action.isCheckable() and action.data() == 16) + size_action.trigger() + first.resizeDocks([first._navigation_dock], [310], Qt.Orientation.Horizontal) + qapp.processEvents() + width = first._navigation_dock.width() + assert first._sidebar_action.isEnabled() + first._sidebar_action.trigger() + assert first._navigation_dock.isHidden() + first.close() + assert str(settings.value("workspace/navigation_visible")).lower() == "false" + second = build() + assert second._theme_name == "light" + assert second._user_font_pt == 16 + assert second._navigation_dock.isHidden() + # Closing a window while the restored panel stays hidden must not replace + # its saved width with the dock's not-yet-laid-out default geometry. + second.close() + third = build() + third._sidebar_action.trigger() + qapp.processEvents() + assert abs(third._navigation_dock.width() - width) <= 2 + assert settings.fileName().endswith("workspace.ini") + + +def test_invalid_preferences_fall_back_to_defaults(window_factory): + build, settings = window_factory + settings.setValue("workspace/theme", "missing") + settings.setValue("workspace/font_pt", "broken") + settings.setValue("workspace/navigation_width", -1) + settings.setValue("workspace/navigation_visible", "broken") + window = build() + assert window._theme_name == "dark" + assert window._user_font_pt == 0 + assert not window._navigation_dock.isHidden() + assert window._navigation_dock.width() >= 200 + + +def test_default_settings_follow_the_current_home(qapp, monkeypatch, tmp_path): + from pathlib import Path + from je_auto_control.gui.workspace_settings import default_settings + + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + settings = default_settings() + settings.setValue("workspace/theme", "light") + settings.sync() + assert Path(settings.fileName()) == tmp_path / ".je_auto_control" / "gui.ini" + + +def test_close_button_closes_its_page_after_tab_insertion(qapp): + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QTabBar, QWidget + from je_auto_control.gui.workspace import ScrollableTabs + + tabs = ScrollableTabs() + page = QWidget() + tabs.addTab(page, "First") + tabs.insertTab(0, QWidget(), "Inserted") + requested = [] + tabs.tabCloseRequested.connect(requested.append) + side = QTabBar.ButtonPosition.RightSide + button = tabs.tabBar().tabButton(1, side) + try: + assert button.focusPolicy() == Qt.FocusPolicy.StrongFocus + button.click() + assert requested == [1] + finally: + tabs.deleteLater() diff --git a/uv.lock b/uv.lock index 9b8ba4cef..f03f50518 100644 --- a/uv.lock +++ b/uv.lock @@ -451,7 +451,7 @@ wheels = [ [[package]] name = "je-auto-control" -version = "0.0.227" +version = "0.0.228" source = { editable = "." } dependencies = [ { name = "cryptography", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, @@ -477,7 +477,6 @@ fuzzy = [ ] gui = [ { name = "pyside6" }, - { name = "qt-material" }, ] locale = [ { name = "babel" }, @@ -525,7 +524,6 @@ requires-dist = [ { name = "python-docx", marker = "extra == 'office'", specifier = ">=1.1" }, { name = "python-pptx", marker = "extra == 'office'", specifier = ">=0.6" }, { name = "python-xlib", marker = "platform_system == 'FreeBSD' or platform_system == 'NetBSD' or platform_system == 'OpenBSD' or sys_platform == 'linux'", specifier = "==0.33" }, - { name = "qt-material", marker = "extra == 'gui'", specifier = "==2.17" }, { name = "rapidfuzz", marker = "extra == 'fuzzy'", specifier = ">=3.0" }, { name = "starlette", marker = "extra == 'signaling'", specifier = ">=1.0.1" }, { name = "uvicorn", marker = "extra == 'signaling'", specifier = ">=0.32" }, @@ -547,18 +545,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/00/418352e5080c4c0364609e2a84486f9a4c20dc9625c8b69829d623336915/je_open_cv-0.0.22-py3-none-any.whl", hash = "sha256:847390e3d0d7d9eaf6178faf9cbc297e1b84a51f38281605ba478f24b026b859", size = 7164960, upload-time = "2023-03-27T09:07:39.866Z" }, ] -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - [[package]] name = "jmespath" version = "1.1.0" @@ -686,91 +672,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, ] -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - [[package]] name = "mss" version = "10.2.0" @@ -4479,18 +4380,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, ] -[[package]] -name = "qt-material" -version = "2.17" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/57/d6473fc7f9b1a81f6bddea555119270818c54be7717f38d0977bb6161408/qt_material-2.17.tar.gz", hash = "sha256:b500a0c1f5ef8f46a8cf037d1aa5bdf9ea4eb618180b6332539dfbdd0647ad0d", size = 1668475, upload-time = "2025-04-21T17:00:25.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/63/e1085fd76bfea103b6c93d133d849f80a5a94b924f31897eb936cabe8459/qt_material-2.17-py3-none-any.whl", hash = "sha256:660523341ae45b79d222bf15e202ec76ba3bf5e7e82170b66759738d7bb4873d", size = 1687312, upload-time = "2025-04-21T17:00:23.761Z" }, -] - [[package]] name = "rapidfuzz" version = "3.14.5" From 2c9d91b7be09e74d8bb48bf5227e6c9fb162868b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 19:48:49 +0800 Subject: [PATCH 002/294] Make the English and Chinese Sphinx builds pass strict validation --- Progress.md | 19 ------- .../Eng/doc/new_features/new_features_doc.rst | 4 +- .../Eng/doc/new_features/v11_features_doc.rst | 4 +- .../Eng/doc/new_features/v12_features_doc.rst | 4 +- .../Eng/doc/new_features/v13_features_doc.rst | 6 +-- .../Eng/doc/new_features/v15_features_doc.rst | 4 +- .../Eng/doc/new_features/v16_features_doc.rst | 4 +- .../Eng/doc/new_features/v17_features_doc.rst | 4 +- .../doc/new_features/v181_features_doc.rst | 2 +- .../Eng/doc/new_features/v18_features_doc.rst | 4 +- .../Eng/doc/new_features/v19_features_doc.rst | 6 +-- .../Eng/doc/new_features/v20_features_doc.rst | 4 +- .../Eng/doc/new_features/v21_features_doc.rst | 4 +- .../Eng/doc/new_features/v22_features_doc.rst | 4 +- .../Eng/doc/new_features/v23_features_doc.rst | 4 +- .../Eng/doc/new_features/v24_features_doc.rst | 4 +- .../Eng/doc/new_features/v25_features_doc.rst | 4 +- .../Eng/doc/new_features/v26_features_doc.rst | 8 +-- .../Eng/doc/new_features/v27_features_doc.rst | 8 +-- .../Eng/doc/new_features/v28_features_doc.rst | 4 +- .../Eng/doc/new_features/v30_features_doc.rst | 6 +-- .../Eng/doc/new_features/v31_features_doc.rst | 4 +- .../Eng/doc/new_features/v51_features_doc.rst | 28 ++++++---- .../Eng/doc/new_features/v7_features_doc.rst | 6 +-- .../Eng/doc/new_features/v9_features_doc.rst | 2 +- .../usb_passthrough_operator_guide.rst | 43 +++++++++------ docs/source/Eng/eng_index.rst | 1 - docs/source/Zh/doc/cli/cli_doc.rst | 6 +-- .../Zh/doc/mcp_server/mcp_server_doc.rst | 2 +- .../Zh/doc/new_features/new_features_doc.rst | 2 +- .../Zh/doc/new_features/v101_features_doc.rst | 2 +- .../Zh/doc/new_features/v102_features_doc.rst | 2 +- .../Zh/doc/new_features/v103_features_doc.rst | 2 +- .../Zh/doc/new_features/v104_features_doc.rst | 2 +- .../Zh/doc/new_features/v105_features_doc.rst | 2 +- .../Zh/doc/new_features/v106_features_doc.rst | 2 +- .../Zh/doc/new_features/v109_features_doc.rst | 2 +- .../Zh/doc/new_features/v10_features_doc.rst | 8 +-- .../Zh/doc/new_features/v111_features_doc.rst | 2 +- .../Zh/doc/new_features/v115_features_doc.rst | 6 +-- .../Zh/doc/new_features/v116_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v119_features_doc.rst | 2 +- .../Zh/doc/new_features/v11_features_doc.rst | 8 +-- .../Zh/doc/new_features/v125_features_doc.rst | 2 +- .../Zh/doc/new_features/v129_features_doc.rst | 6 +-- .../Zh/doc/new_features/v13_features_doc.rst | 2 +- .../Zh/doc/new_features/v144_features_doc.rst | 4 +- .../Zh/doc/new_features/v145_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v14_features_doc.rst | 4 +- .../Zh/doc/new_features/v154_features_doc.rst | 4 +- .../Zh/doc/new_features/v157_features_doc.rst | 4 +- .../Zh/doc/new_features/v158_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v159_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v160_features_doc.rst | 6 +-- .../Zh/doc/new_features/v161_features_doc.rst | 6 +-- .../Zh/doc/new_features/v162_features_doc.rst | 6 +-- .../Zh/doc/new_features/v163_features_doc.rst | 6 +-- .../Zh/doc/new_features/v164_features_doc.rst | 6 +-- .../Zh/doc/new_features/v165_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v166_features_doc.rst | 16 +++--- .../Zh/doc/new_features/v167_features_doc.rst | 14 ++--- .../Zh/doc/new_features/v168_features_doc.rst | 6 +-- .../Zh/doc/new_features/v169_features_doc.rst | 4 +- .../Zh/doc/new_features/v170_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v171_features_doc.rst | 6 +-- .../Zh/doc/new_features/v172_features_doc.rst | 8 +-- .../Zh/doc/new_features/v174_features_doc.rst | 6 +-- .../Zh/doc/new_features/v175_features_doc.rst | 4 +- .../Zh/doc/new_features/v176_features_doc.rst | 6 +-- .../Zh/doc/new_features/v177_features_doc.rst | 16 +++--- .../Zh/doc/new_features/v178_features_doc.rst | 16 +++--- .../Zh/doc/new_features/v179_features_doc.rst | 16 +++--- .../Zh/doc/new_features/v17_features_doc.rst | 8 +-- .../Zh/doc/new_features/v180_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v185_features_doc.rst | 4 +- .../Zh/doc/new_features/v189_features_doc.rst | 2 +- .../Zh/doc/new_features/v194_features_doc.rst | 4 +- .../Zh/doc/new_features/v201_features_doc.rst | 4 +- .../Zh/doc/new_features/v207_features_doc.rst | 2 +- .../Zh/doc/new_features/v209_features_doc.rst | 8 +-- .../Zh/doc/new_features/v210_features_doc.rst | 6 +-- .../Zh/doc/new_features/v212_features_doc.rst | 12 ++--- .../Zh/doc/new_features/v214_features_doc.rst | 8 +-- .../Zh/doc/new_features/v216_features_doc.rst | 6 +-- .../Zh/doc/new_features/v217_features_doc.rst | 6 +-- .../Zh/doc/new_features/v219_features_doc.rst | 4 +- .../Zh/doc/new_features/v221_features_doc.rst | 6 +-- .../Zh/doc/new_features/v222_features_doc.rst | 6 +-- .../Zh/doc/new_features/v223_features_doc.rst | 6 +-- .../Zh/doc/new_features/v2_features_doc.rst | 8 +-- .../Zh/doc/new_features/v31_features_doc.rst | 4 +- .../Zh/doc/new_features/v34_features_doc.rst | 2 +- .../Zh/doc/new_features/v36_features_doc.rst | 4 +- .../Zh/doc/new_features/v37_features_doc.rst | 52 ++++++++++++++----- .../Zh/doc/new_features/v3_features_doc.rst | 6 +-- .../Zh/doc/new_features/v40_features_doc.rst | 6 +-- .../Zh/doc/new_features/v42_features_doc.rst | 4 +- .../Zh/doc/new_features/v48_features_doc.rst | 2 +- .../Zh/doc/new_features/v4_features_doc.rst | 4 +- .../Zh/doc/new_features/v51_features_doc.rst | 28 ++++++---- .../Zh/doc/new_features/v55_features_doc.rst | 4 +- .../Zh/doc/new_features/v58_features_doc.rst | 8 +-- .../Zh/doc/new_features/v59_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v5_features_doc.rst | 4 +- .../Zh/doc/new_features/v60_features_doc.rst | 20 +++---- .../Zh/doc/new_features/v61_features_doc.rst | 8 +-- .../Zh/doc/new_features/v62_features_doc.rst | 2 +- .../Zh/doc/new_features/v67_features_doc.rst | 6 +-- .../Zh/doc/new_features/v69_features_doc.rst | 10 ++-- .../Zh/doc/new_features/v6_features_doc.rst | 8 +-- .../Zh/doc/new_features/v70_features_doc.rst | 6 +-- .../Zh/doc/new_features/v71_features_doc.rst | 6 +-- .../Zh/doc/new_features/v72_features_doc.rst | 8 +-- .../Zh/doc/new_features/v73_features_doc.rst | 2 +- .../Zh/doc/new_features/v74_features_doc.rst | 2 +- .../Zh/doc/new_features/v75_features_doc.rst | 2 +- .../Zh/doc/new_features/v76_features_doc.rst | 2 +- .../Zh/doc/new_features/v77_features_doc.rst | 2 +- .../Zh/doc/new_features/v78_features_doc.rst | 2 +- .../Zh/doc/new_features/v80_features_doc.rst | 2 +- .../Zh/doc/new_features/v81_features_doc.rst | 2 +- .../Zh/doc/new_features/v82_features_doc.rst | 4 +- .../Zh/doc/new_features/v83_features_doc.rst | 2 +- .../Zh/doc/new_features/v84_features_doc.rst | 2 +- .../Zh/doc/new_features/v85_features_doc.rst | 2 +- .../Zh/doc/new_features/v86_features_doc.rst | 2 +- .../Zh/doc/new_features/v87_features_doc.rst | 2 +- .../Zh/doc/new_features/v88_features_doc.rst | 2 +- .../Zh/doc/new_features/v89_features_doc.rst | 2 +- .../Zh/doc/new_features/v90_features_doc.rst | 2 +- .../Zh/doc/new_features/v92_features_doc.rst | 2 +- .../Zh/doc/new_features/v93_features_doc.rst | 2 +- .../Zh/doc/new_features/v94_features_doc.rst | 2 +- .../Zh/doc/new_features/v95_features_doc.rst | 2 +- .../Zh/doc/new_features/v96_features_doc.rst | 2 +- .../Zh/doc/new_features/v97_features_doc.rst | 2 +- .../Zh/doc/new_features/v98_features_doc.rst | 8 +-- .../Zh/doc/new_features/v99_features_doc.rst | 2 +- .../usb_passthrough_design.rst | 16 +++--- .../usb_passthrough_operator_guide.rst | 43 +++++++++------ docs/source/Zh/zh_index.rst | 1 - docs/source/conf.py | 2 +- docs/source/getting_started/run_in_ci.rst | 4 +- docs/updates/2026-10.md | 7 +++ docs/updates/README.md | 3 +- 145 files changed, 496 insertions(+), 439 deletions(-) diff --git a/Progress.md b/Progress.md index 187aa55f8..703bb580d 100644 --- a/Progress.md +++ b/Progress.md @@ -58,24 +58,6 @@ `gui/language_wrapper/{english,japanese,traditional_chinese,simplified_chinese}.py` (1,326/1,213/1,193/1,192,語系字串表;以上皆 2026-09-23 實測)。 -### 2026-08-19 決議:上表的實測行數就是新的上限 - -2026-08-18 重新實測時,表上原有的七列**全部**變長,而 `CLAUDE.md` 明寫 -「列上的檔案不得再變長,要再長就得先拆」,所以這裡曾標成 `[DECIDE]`。 -**維護者已於 2026-08-19 拍板:接受實測數字當新基準**——不為了回到舊數字而去拆 -`_handlers.py`(4,789)與 `webrtc_panel.py`。上表的行數即是各自的新上限, -規則不變:只准變短,再變長就得先拆。 -(`_handlers.py` 後來還是拆了:2026-09-22 拆出 QA 主題,2026-09-23 再拆出九個主題模組, -本體降到 522 行、離開上表。見 `docs/updates/` 的 U-20260922-05 與 U-20260923-09。) - -同一批裡有六個檔案在 2026-08-19 已經拆回線內、從表上移除,做法寫在 -commit `46f4cd5` 的說明裡(`docs/updates/` 沒有對應條目:舊的 `WHATS_NEW.md` 從沒記過這件事)。 - -行數沒有任何 CI 在把關(`quality.yml` 的五個 job 裡只有 ruff 管到這一節的限制,而它只管行寬), -所以這張表只會在有人手動實測時才會被發現對不上——上次就是。 - ---- - ## Windows arm64:裝得起來了,但少了影像與加密 `TODO` — 上游仍未發 wheel(`opencv-python`、`cryptography`),但安裝本身不再是卡點 @@ -253,7 +235,6 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **`pytest --cov` 沒有重新量過**:進入點搬到 `je_auto_control_pytest` 之後,它是否還少算,未知;規則仍是 `coverage run -m pytest`。 - **快到 750 行上限的檔案**:`utils/usb/passthrough/viewer_client.py`(748)、`gui/remote_desktop/connection_screen.py`(729)、 `utils/mcp_server/server.py`(734)、`utils/mcp_server/http_transport.py`(727)、`utils/agent/backends/anthropic_computer_use.py`(723)——下一次要加東西就得先拆。 -- **Sphinx 嚴格建置未通過**:`docs/source` 的英/中文文件仍有標題線過短等既有警告;需修完後以 `-W --keep-going` 重建兩種語言並檢查渲染。 --- diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 560559b63..18daec7e2 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1,6 +1,6 @@ -===================== +====================== New Features (2026-04) -===================== +====================== This page documents the April 2026 additions to AutoControl. Every new feature ships with a headless Python API **and** a GUI affordance, and is diff --git a/docs/source/Eng/doc/new_features/v11_features_doc.rst b/docs/source/Eng/doc/new_features/v11_features_doc.rst index 634c06ffe..978631e70 100644 --- a/docs/source/Eng/doc/new_features/v11_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v11_features_doc.rst @@ -40,7 +40,7 @@ The ``AC_generate_data`` command writes a file (then feed it to MCP registry manifest -==================== +===================== Publish a ``server.json`` describing this AutoControl MCP server so MCP-aware agents and IDEs can discover and install it. The manifest is @@ -56,7 +56,7 @@ touching the registry-valid core fields). Also exposed as Risk-based test selection -======================== +========================= Instead of always running the whole suite, rank flows by how *risky* they are — recently failing, flaky, stale, or never-run — using the run-history diff --git a/docs/source/Eng/doc/new_features/v12_features_doc.rst b/docs/source/Eng/doc/new_features/v12_features_doc.rst index a70471f53..f482e4e42 100644 --- a/docs/source/Eng/doc/new_features/v12_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v12_features_doc.rst @@ -13,7 +13,7 @@ Builder): a native-UI **element repository** (object repository) and a Element repository -================= +================== Save native-UI locators under friendly names once, reuse them everywhere — the classic RPA *object repository*. A flow references @@ -40,7 +40,7 @@ the matching ``ac_element_*`` MCP tools). Step debugger and tracer -======================= +======================== Run an action list one command at a time with breakpoints, single-step, and live variable inspection. Stepping reuses one executor instance, so diff --git a/docs/source/Eng/doc/new_features/v13_features_doc.rst b/docs/source/Eng/doc/new_features/v13_features_doc.rst index f674c1099..5e64ae864 100644 --- a/docs/source/Eng/doc/new_features/v13_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v13_features_doc.rst @@ -13,7 +13,7 @@ guardrail**, and an **A2A agent card**. Skill / playbook library -======================= +======================== Agents accumulate playbooks — "log in", "export the report", "dismiss the cookie banner". A :class:`SkillLibrary` stores each as a named action @@ -38,7 +38,7 @@ in-memory macro registry. Prompt-injection guardrail -========================= +========================== When a computer-use agent feeds screen scrapes / OCR text into an LLM, a hostile page can smuggle instructions ("ignore previous instructions and @@ -59,7 +59,7 @@ carries a severity; the score sums high=2 / medium=1. Exposed as A2A agent card -============= +============== The A2A protocol lets agents discover each other through an *Agent Card* — a JSON document advertising identity, endpoint, and skills. Publishing one diff --git a/docs/source/Eng/doc/new_features/v15_features_doc.rst b/docs/source/Eng/doc/new_features/v15_features_doc.rst index 21647980f..1aa209bcc 100644 --- a/docs/source/Eng/doc/new_features/v15_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v15_features_doc.rst @@ -13,7 +13,7 @@ tools, Script Builder): a persistent **agent episodic memory** store and a Agent episodic memory -==================== +===================== An agent that re-derives "how do I log in to this app" every run wastes tokens and repeats mistakes. :class:`AgentMemory` records each episode — @@ -38,7 +38,7 @@ without changing the API. Commands: ``AC_memory_remember`` / Deterministic run -================ +================= Time and randomness are two of the top causes of flaky automation. :class:`DeterministicRun` pins both for a ``with`` block and records the diff --git a/docs/source/Eng/doc/new_features/v16_features_doc.rst b/docs/source/Eng/doc/new_features/v16_features_doc.rst index b36a731ce..97b5faa5d 100644 --- a/docs/source/Eng/doc/new_features/v16_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v16_features_doc.rst @@ -16,7 +16,7 @@ June 2025). Pure standard library; wired through the full stack. Conformance audit -================ +================= :: @@ -41,7 +41,7 @@ Findings are filtered to the requested conformance ``level`` (``A`` / Target Size rule -=============== +================ ``audit_target_size(elements, min_px=24)`` flags interactive elements whose bounds are smaller than ``min_px`` on either side (elements with unknown diff --git a/docs/source/Eng/doc/new_features/v17_features_doc.rst b/docs/source/Eng/doc/new_features/v17_features_doc.rst index 738e0acb5..95738c047 100644 --- a/docs/source/Eng/doc/new_features/v17_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v17_features_doc.rst @@ -17,7 +17,7 @@ executor commands, MCP tools, Script Builder). Python API -========= +========== :: @@ -44,7 +44,7 @@ absent), ``change`` (present, value differs). Subscribe to a subset via Executor / MCP commands -====================== +======================= * ``AC_observe_add`` — watch ``kind`` (``image`` / ``text`` / ``pixel``) for ``event`` and run ``actions`` when it fires (the watchdog pattern, diff --git a/docs/source/Eng/doc/new_features/v181_features_doc.rst b/docs/source/Eng/doc/new_features/v181_features_doc.rst index 77c327349..c55942fa0 100644 --- a/docs/source/Eng/doc/new_features/v181_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v181_features_doc.rst @@ -1,5 +1,5 @@ Extended UIA Control Patterns (Expand / Select / Range / Scroll) -=============================================================== +================================================================ The accessibility backend shipped only four control patterns — Value, Invoke, Toggle and a read-only Grid dump. That left the controls automation hits most often undriveable by their diff --git a/docs/source/Eng/doc/new_features/v18_features_doc.rst b/docs/source/Eng/doc/new_features/v18_features_doc.rst index 0cfd711c9..07c800733 100644 --- a/docs/source/Eng/doc/new_features/v18_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v18_features_doc.rst @@ -13,7 +13,7 @@ MCP tools, Script Builder): a **CycloneDX SBOM generator** and a CycloneDX SBOM -============= +============== Supply-chain regulation (EU Cyber Resilience Act, US EO 14028) increasingly requires a machine-readable Software Bill of Materials. ``build_sbom`` walks @@ -35,7 +35,7 @@ action files alongside code. Exposed as ``AC_generate_sbom`` / Duration-aware suite sharding -============================ +============================= Splitting a suite across N workers by *count* wastes time when tests differ in duration — the slowest worker defines wall-clock. ``shard_flows`` balances diff --git a/docs/source/Eng/doc/new_features/v19_features_doc.rst b/docs/source/Eng/doc/new_features/v19_features_doc.rst index 16200ec35..a979d1349 100644 --- a/docs/source/Eng/doc/new_features/v19_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v19_features_doc.rst @@ -13,7 +13,7 @@ executor commands, MCP tools, Script Builder). Row schema validation -==================== +===================== Validate scraped / loaded rows against a declarative schema before they reach an ERP or form — bad data caught here doesn't corrupt downstream:: @@ -37,7 +37,7 @@ Rules: ``type`` / ``required`` / ``regex`` / ``min`` / ``max`` / Field extraction -=============== +================ Pull structured values out of free text / OCR blobs with named regex presets (plus your own ``patterns``):: @@ -54,7 +54,7 @@ Presets: ``email`` / ``url`` / ``ipv4`` / ``phone`` / ``date_iso`` / Row masking -========== +=========== Mask sensitive columns before exporting rows / reports (the existing redaction is screenshot-only):: diff --git a/docs/source/Eng/doc/new_features/v20_features_doc.rst b/docs/source/Eng/doc/new_features/v20_features_doc.rst index 3f4fd20b9..26427b619 100644 --- a/docs/source/Eng/doc/new_features/v20_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v20_features_doc.rst @@ -12,7 +12,7 @@ executor commands, MCP tools, Script Builder). Pseudo-localization -================== +=================== Accent and pad UI strings (preserving placeholders) to flush out hardcoded text and pre-stress layout *before* any real translation exists:: @@ -50,7 +50,7 @@ the live a11y tree unless ``elements`` are supplied). Catalog completeness -=================== +==================== Diff a translation catalog against a base locale for missing / orphaned / empty keys and placeholder mismatches — a CI gate against blank UI:: diff --git a/docs/source/Eng/doc/new_features/v21_features_doc.rst b/docs/source/Eng/doc/new_features/v21_features_doc.rst index 726d3b69c..e97b41259 100644 --- a/docs/source/Eng/doc/new_features/v21_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v21_features_doc.rst @@ -13,7 +13,7 @@ MCP tools, Script Builder). Flow checkpoint & resume -======================= +======================== A multi-hour unattended flow that dies at step 400 should not restart from zero. :func:`run_resumable` persists ``{run_id, step_index, variables}`` @@ -45,7 +45,7 @@ Executor / MCP commands: ``py.typed`` marker -================== +=================== The package now ships a PEP 561 ``py.typed`` marker, so Mypy / Pyright / Pylance honor AutoControl's inline type annotations in downstream code — diff --git a/docs/source/Eng/doc/new_features/v22_features_doc.rst b/docs/source/Eng/doc/new_features/v22_features_doc.rst index 13c53a210..dc99a901f 100644 --- a/docs/source/Eng/doc/new_features/v22_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v22_features_doc.rst @@ -16,7 +16,7 @@ dependency); wired through the full stack. Numbering and the legend -======================= +======================== :: @@ -34,7 +34,7 @@ unit-testable with synthetic elements. Live "mark then click" loop -========================== +=========================== :: diff --git a/docs/source/Eng/doc/new_features/v23_features_doc.rst b/docs/source/Eng/doc/new_features/v23_features_doc.rst index 2d38bbf35..a36857b95 100644 --- a/docs/source/Eng/doc/new_features/v23_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v23_features_doc.rst @@ -14,7 +14,7 @@ effect and orient itself. Pure standard library; full stack. Snapshot & diff -============== +=============== :: @@ -34,7 +34,7 @@ the *live* tree (caching the baseline). Exposed as ``AC_screen_snapshot`` / Describe the screen -================== +=================== :: diff --git a/docs/source/Eng/doc/new_features/v24_features_doc.rst b/docs/source/Eng/doc/new_features/v24_features_doc.rst index dc6826670..a4aecc4c9 100644 --- a/docs/source/Eng/doc/new_features/v24_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v24_features_doc.rst @@ -13,7 +13,7 @@ deterministically with a fake clock and a recording sink. Timed timeline replay -==================== +===================== :: @@ -31,7 +31,7 @@ to ``[min_gap, max_gap]``). Event ``op`` is one of ``move`` / ``click`` / Input-sequence DSL -================= +================== :: diff --git a/docs/source/Eng/doc/new_features/v25_features_doc.rst b/docs/source/Eng/doc/new_features/v25_features_doc.rst index 3dc734d5c..fb6121d52 100644 --- a/docs/source/Eng/doc/new_features/v25_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v25_features_doc.rst @@ -17,7 +17,7 @@ this adds the reusable :class:`RetryPolicy` callable wrapper and the new RetryPolicy -========== +=========== :: @@ -32,7 +32,7 @@ re-raising the last error when attempts are exhausted. CircuitBreaker -============= +============== :: diff --git a/docs/source/Eng/doc/new_features/v26_features_doc.rst b/docs/source/Eng/doc/new_features/v26_features_doc.rst index f8326eb7f..ed2c6b915 100644 --- a/docs/source/Eng/doc/new_features/v26_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v26_features_doc.rst @@ -1,6 +1,6 @@ -================================================== +============================================================== New Features (2026-06-19) — CI Annotations & Clipboard History -================================================== +============================================================== Two pure-standard-library utilities: emit CI annotations from results, and keep a searchable clipboard history. Full stack. @@ -11,7 +11,7 @@ keep a searchable clipboard history. Full stack. CI workflow annotations -====================== +======================= :: @@ -31,7 +31,7 @@ into GitHub Actions workflow commands so failures surface **inline** in a PR Clipboard history -================ +================= :: diff --git a/docs/source/Eng/doc/new_features/v27_features_doc.rst b/docs/source/Eng/doc/new_features/v27_features_doc.rst index 77224fdb4..31e946056 100644 --- a/docs/source/Eng/doc/new_features/v27_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v27_features_doc.rst @@ -1,6 +1,6 @@ -================================================== +======================================================== New Features (2026-06-19) — Heal Analytics & Secret Scan -================================================== +======================================================== Two pure-standard-library audit/analysis tools: aggregate the self-healing log into drift metrics, and scan action JSON for hardcoded secrets. Full @@ -12,7 +12,7 @@ stack. Self-heal analytics -================== +=================== :: @@ -28,7 +28,7 @@ they fail. Exposed as ``AC_heal_stats`` / ``ac_heal_stats``. Secret scan -========== +=========== :: diff --git a/docs/source/Eng/doc/new_features/v28_features_doc.rst b/docs/source/Eng/doc/new_features/v28_features_doc.rst index 0ca1564f3..6bc41065a 100644 --- a/docs/source/Eng/doc/new_features/v28_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v28_features_doc.rst @@ -1,6 +1,6 @@ -================================================== +======================================================= New Features (2026-06-19) — Process-Doc (SOP) Generator -================================================== +======================================================= Turn a recorded / authored action list into a numbered, human-readable **standard operating procedure** — a structured step list plus an HTML diff --git a/docs/source/Eng/doc/new_features/v30_features_doc.rst b/docs/source/Eng/doc/new_features/v30_features_doc.rst index 09f1cd515..6c3ab6d21 100644 --- a/docs/source/Eng/doc/new_features/v30_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v30_features_doc.rst @@ -1,6 +1,6 @@ -================================================== +====================================================== New Features (2026-06-19) — MCP Structured Tool Output -================================================== +====================================================== The MCP server now supports the 2025-06-18 **structured tool output** spec feature: a tool may declare an ``outputSchema``, and its dict result is @@ -16,7 +16,7 @@ errors. Pure standard library; an MCP-framework enhancement (no new Declaring an output schema -========================= +========================== :: diff --git a/docs/source/Eng/doc/new_features/v31_features_doc.rst b/docs/source/Eng/doc/new_features/v31_features_doc.rst index 67e5f7d00..3c5984d66 100644 --- a/docs/source/Eng/doc/new_features/v31_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v31_features_doc.rst @@ -16,7 +16,7 @@ socket server, the scheduler, and MCP. Pure standard library Authoring a plugin -================= +================== A plugin package exposes an entry point whose target is a factory returning a ``{command_name: handler}`` mapping:: @@ -31,7 +31,7 @@ a ``{command_name: handler}`` mapping:: Discovering & loading -==================== +===================== :: diff --git a/docs/source/Eng/doc/new_features/v51_features_doc.rst b/docs/source/Eng/doc/new_features/v51_features_doc.rst index 52c975774..745f02ce3 100644 --- a/docs/source/Eng/doc/new_features/v51_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v51_features_doc.rst @@ -6,16 +6,24 @@ do wildcards, recursive descent, or filters, so API/DB responses with arrays are awkward to extract from. ``json_query`` adds a focused JSONPath subset over already-parsed JSON: -================ =================================================== -Syntax Meaning -================ =================================================== -``$`` root (optional prefix) -``.name`` member access -``[n]`` ``[-n]`` list index (negative from the end) -``*`` ``[*]`` wildcard (all members / elements) -``..`` recursive descent -``[?(@.k op v)]`` filter array elements (``op`` ∈ ``== != < <= > >=``) -================ =================================================== +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Syntax + - Meaning + * - ``$`` + - root (optional prefix) + * - ``.name`` + - member access + * - ``[n]`` ``[-n]`` + - list index (negative from the end) + * - ``*`` ``[*]`` + - wildcard (all members / elements) + * - ``..`` + - recursive descent + * - ``[?(@.k op v)]`` + - filter array elements (``op`` ∈ ``== != < <= > >=``) A filter field may be nested (``@.a.b``), and ``[?(@.k)]`` keeps the elements that have ``k``; on an object a filter selects among its member values. The diff --git a/docs/source/Eng/doc/new_features/v7_features_doc.rst b/docs/source/Eng/doc/new_features/v7_features_doc.rst index 8ae3706bf..038147bbb 100644 --- a/docs/source/Eng/doc/new_features/v7_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v7_features_doc.rst @@ -40,7 +40,7 @@ Executor commands: ``AC_control_get_value``, ``AC_control_set_value``. Invoking and toggling -==================== +===================== :: @@ -57,7 +57,7 @@ Executor commands: ``AC_control_invoke``, ``AC_control_toggle``. Reading tables / grids -==================== +====================== :: @@ -73,7 +73,7 @@ Executor command: ``AC_read_table``. Targeting controls -================= +================== Every call accepts the same matchers — provide whichever uniquely identify the control: diff --git a/docs/source/Eng/doc/new_features/v9_features_doc.rst b/docs/source/Eng/doc/new_features/v9_features_doc.rst index 2dc9771f9..f00d78751 100644 --- a/docs/source/Eng/doc/new_features/v9_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v9_features_doc.rst @@ -56,7 +56,7 @@ window-wait / type / confirm steps go through an injectable Locked-session guard -=================== +==================== Unattended runs silently fail when the workstation is locked or the RDP session is disconnected — input no-ops or throws. Check first:: diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst index bfb33fc12..4ab0814da 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -290,21 +290,34 @@ Everything the GUI does is also an executor command, so JSON action files, the socket server, and the scheduler can drive USB passthrough with no GUI: -================================ ============================================ -Command Purpose -================================ ============================================ -``AC_usb_passthrough_enable`` Toggle the feature flag (``enabled`` bool) -``AC_usb_passthrough_status`` Report whether passthrough is enabled -``AC_usb_acl_list`` List ACL rules + default + integrity state -``AC_usb_acl_add`` Add a per-device rule -``AC_usb_acl_remove`` Remove a rule -``AC_usb_acl_set_default`` Set the default policy (allow/deny) -``AC_usb_acl_export`` / ``_import`` Back up / restore the ACL as JSON -``AC_usb_loopback_list`` List ACL-visible devices over loopback -``AC_usb_loopback_open`` Claim a local device + read its descriptor -``AC_usb_remote_list`` List a remote host's devices (live WebRTC) -``AC_usb_remote_open`` Claim a remote device + read its descriptor -================================ ============================================ +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Command + - Purpose + * - ``AC_usb_passthrough_enable`` + - Toggle the feature flag (``enabled`` bool) + * - ``AC_usb_passthrough_status`` + - Report whether passthrough is enabled + * - ``AC_usb_acl_list`` + - List ACL rules + default + integrity state + * - ``AC_usb_acl_add`` + - Add a per-device rule + * - ``AC_usb_acl_remove`` + - Remove a rule + * - ``AC_usb_acl_set_default`` + - Set the default policy (allow/deny) + * - ``AC_usb_acl_export`` / ``_import`` + - Back up / restore the ACL as JSON + * - ``AC_usb_loopback_list`` + - List ACL-visible devices over loopback + * - ``AC_usb_loopback_open`` + - Claim a local device + read its descriptor + * - ``AC_usb_remote_list`` + - List a remote host's devices (live WebRTC) + * - ``AC_usb_remote_open`` + - Claim a remote device + read its descriptor Example JSON action:: diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index e826e9bb4..b42f2267b 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -21,7 +21,6 @@ Comprehensive guides for all AutoControl features. doc/socket_driver/socket_driver_doc doc/mcp_server/mcp_server_doc doc/ai_agents/ai_agents_doc - doc/ai_agents/ai_agents_doc doc/critical_exit/critical_exit_doc doc/cli/cli_doc doc/create_project/create_project_doc diff --git a/docs/source/Zh/doc/cli/cli_doc.rst b/docs/source/Zh/doc/cli/cli_doc.rst index e76b14f70..3444fd8a3 100644 --- a/docs/source/Zh/doc/cli/cli_doc.rst +++ b/docs/source/Zh/doc/cli/cli_doc.rst @@ -37,9 +37,9 @@ AutoControl 可以直接從命令列執行自動化腳本。 ====================== ``AC_add_package_to_executor`` 與 ``AC_add_package_to_callback_executor`` 會拒絕沒有被放行的套件。 -從命令列放行的方式有兩種:環境變數 ``JE_AUTOCONTROL_ALLOWED_PACKAGES``(以逗號分隔的套件名稱,含子模組), -對上面的旗標、``start-server``、``start-rest`` 與 MCP server 等所有入口都適用;以及 ``je_auto_control run`` -的 ``--allow-package NAME``(可重複),只對該次執行有效。 +從命令列放行的方式有兩種:環境變數 ``JE_AUTOCONTROL_ALLOWED_PACKAGES`` (以逗號分隔的套件名稱,含子模組), +對上面的旗標、 ``start-server`` 、 ``start-rest`` 與 MCP server 等所有入口都適用;以及 ``je_auto_control run`` +的 ``--allow-package NAME`` (可重複),只對該次執行有效。 .. code-block:: bash diff --git a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst index 6118174b4..89c85e2f1 100644 --- a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst +++ b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst @@ -459,7 +459,7 @@ client 不用改,可以和 2026-07-28 的 client 並存。 只有名稱符合時才會解析,其餘回工具執行錯誤。沒設時和以前一樣,任何變數都讀得到—— 包括放 API 金鑰的那些。 -在程式裡,同一份設定是 ``server.argument_policy``(:class:`ArgumentPolicy`,內含 +在程式裡,同一份設定是 ``server.argument_policy`` ( :class:`ArgumentPolicy` ,內含 :class:`je_auto_control.PathPolicy` 與允許清單);自己建立的伺服器可以指派另一個。 破壞性動作確認(Elicitation) diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index 9b96dd85b..2792a7fd8 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -288,7 +288,7 @@ Action-JSON 指令:``AC_vlm_locate``、``AC_vlm_click``。GUI: ======================= 排程器、觸發器、熱鍵守護程序、REST API 與 GUI 手動回放的每一次執行 -都會被寫入 ``~/.je_auto_control/history.db``(SQLite)。失敗時會自動 +都會被寫入 ``~/.je_auto_control/history.db`` (SQLite)。失敗時會自動 擷取螢幕截圖並附到該筆紀錄上:: from je_auto_control import default_history_store diff --git a/docs/source/Zh/doc/new_features/v101_features_doc.rst b/docs/source/Zh/doc/new_features/v101_features_doc.rst index 5980c1691..97c34ba7c 100644 --- a/docs/source/Zh/doc/new_features/v101_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v101_features_doc.rst @@ -1,5 +1,5 @@ 單序列異常偵測 -============ +============== ``data_drift`` 回答的是「兩個批次之間*分布*是否偏移」—— 無法指出單一即時序列中*哪一個*值異常 —— 而 ``slo.burn_alerts`` 只對錯誤預算燃燒設門檻,不針對任意度量值(延遲尖峰、成本尖峰、CPU)。本功能以 diff --git a/docs/source/Zh/doc/new_features/v102_features_doc.rst b/docs/source/Zh/doc/new_features/v102_features_doc.rst index 136121337..ac8f7c647 100644 --- a/docs/source/Zh/doc/new_features/v102_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v102_features_doc.rst @@ -1,5 +1,5 @@ 移動平均平滑 -========== +============ ``stats.describe`` 彙總整個樣本,``timeseries`` 把計數器滾成速率,但沒有東西能平滑雜訊訊號或加權近期點。 本功能加入尾端的簡單 / 加權 / 指數加權移動平均,以及一個通用的滾動歸約器。 diff --git a/docs/source/Zh/doc/new_features/v103_features_doc.rst b/docs/source/Zh/doc/new_features/v103_features_doc.rst index 1249a8f23..d0b110c4a 100644 --- a/docs/source/Zh/doc/new_features/v103_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v103_features_doc.rst @@ -1,5 +1,5 @@ 冪等鍵儲存 -======== +========== ``resilience.RetryPolicy`` 在重試時*重新執行*,``work_queue`` 只對進行中的參照去重 —— 沒有東西快取 第一次的結果,讓重複請求能回傳*相同*回應而不重跑副作用。這是 Stripe 的冪等模式:註冊一個鍵、把工作 diff --git a/docs/source/Zh/doc/new_features/v104_features_doc.rst b/docs/source/Zh/doc/new_features/v104_features_doc.rst index 06f45b4c7..17b62a880 100644 --- a/docs/source/Zh/doc/new_features/v104_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v104_features_doc.rst @@ -1,5 +1,5 @@ 時間視窗去重 -========== +============ ``work_queue`` 只對 ``new`` / ``in_progress`` 參照去重 —— 一旦項目完成,同一參照又會再入列,重送的 webhook 也會被重複處理。本功能補上缺少的「最近 N 秒看過這個 id → 丟棄」收件匣,把至少一次投遞轉換成 diff --git a/docs/source/Zh/doc/new_features/v105_features_doc.rst b/docs/source/Zh/doc/new_features/v105_features_doc.rst index af5ba4587..733435b53 100644 --- a/docs/source/Zh/doc/new_features/v105_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v105_features_doc.rst @@ -1,5 +1,5 @@ 逐串流序號間隙偵測 -================ +================== 沒有東西追蹤每個串流的單調序號以偵測遺漏、亂序或重複的訊息。``dedup_window`` 說「之前看過這個 id」; 本功能補上互補的一面:分類每個序號,並追蹤每個串流的未決間隙與 high-water 標記。 diff --git a/docs/source/Zh/doc/new_features/v106_features_doc.rst b/docs/source/Zh/doc/new_features/v106_features_doc.rst index 36ef7623f..111960659 100644 --- a/docs/source/Zh/doc/new_features/v106_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v106_features_doc.rst @@ -1,5 +1,5 @@ 樂觀並行版本儲存 -============== +================ ``http_conditional`` 以 ETag 做*讀取*快取(``If-None-Match`` / 304),但從不用於*寫入*並行 (``If-Match`` / 版本檢查)。沒有本地的 compare-and-swap / 版本化記錄儲存來做「只在版本未變時更新」。 diff --git a/docs/source/Zh/doc/new_features/v109_features_doc.rst b/docs/source/Zh/doc/new_features/v109_features_doc.rst index 399793f84..46b4e0857 100644 --- a/docs/source/Zh/doc/new_features/v109_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v109_features_doc.rst @@ -1,5 +1,5 @@ 易混淆字元 / 同形異義字偵測 -========================== +=========================== ``secrets_scan`` 找出疑似機密的權杖、``guardrail`` 篩檢提示注入,但沒有任何功能能抓出*視覺*仿冒:西里爾字母 ``"а"``(U+0430)與拉丁字母 ``"a"``(U+0061)在像素上完全相同,因此 ``"pаypal"``(其中 ``а`` 為西里爾字母) diff --git a/docs/source/Zh/doc/new_features/v10_features_doc.rst b/docs/source/Zh/doc/new_features/v10_features_doc.rst index 72eed8665..e55f03ced 100644 --- a/docs/source/Zh/doc/new_features/v10_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v10_features_doc.rst @@ -51,10 +51,10 @@ Dispatcher / performer 兩種失敗類型,對應 REFramework: -* **application**(暫時性——逾時、stale element):重試至 ``max_retries`` - (預設 3)次,然後標為 ``failed``。 -* **business**(資料本身無效):永不重試——立即標為 ``failed``。請丟出 - :class:`BusinessError` 或傳 ``kind="business"``。 +* **application** (暫時性——逾時、stale element):重試至 ``max_retries`` + (預設 3)次,然後標為 ``failed`` 。 +* **business** (資料本身無效):永不重試——立即標為 ``failed`` 。請丟出 + :class:`BusinessError` 或傳 ``kind="business"`` 。 ``stats()`` 回傳各狀態計數(``new`` / ``in_progress`` / ``success`` / ``failed``),供儀表板與執行報告使用。資料庫無法開啟或使用(不是 SQLite 檔、鎖定逾時)時丟出 diff --git a/docs/source/Zh/doc/new_features/v111_features_doc.rst b/docs/source/Zh/doc/new_features/v111_features_doc.rst index 9555eb7ba..e0bbb3cf6 100644 --- a/docs/source/Zh/doc/new_features/v111_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v111_features_doc.rst @@ -1,7 +1,7 @@ 雙向文字 QA(Trojan-Source 掃描) ================================ -``confusables`` 抓出相似的*字元*,但隱形的 Unicode *方向格式控制*是另一種危害。嵌入/覆寫 +``confusables`` 抓出相似的 *字元* ,但隱形的 Unicode *方向格式控制* 是另一種危害。嵌入/覆寫 (LRE/RLE/LRO/RLO/PDF)、隔離(LRI/RLI/FSI/PDI)與標記(LRM/RLM/ALM)可以悄悄改變文字的呈現順序。這既是 RTL 在地化 QA 的缺口,也是「Trojan Source」攻擊(CVE-2021-42574)的根源——覆寫控制讓原始碼讀起來與實際執行 不同。 diff --git a/docs/source/Zh/doc/new_features/v115_features_doc.rst b/docs/source/Zh/doc/new_features/v115_features_doc.rst index 5a0216b0e..fadc89122 100644 --- a/docs/source/Zh/doc/new_features/v115_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v115_features_doc.rst @@ -26,9 +26,9 @@ ISBN、EAN 驗證所依據的基本元件。 damm_check_digit("572") # '4' mod97_10_validate("3214282912345698765432161182") # True (IBAN 引擎) -- **Luhn**(mod 10):信用卡、IMEI、多種國民身分碼——可抓出所有單一數字錯誤與多數相鄰換位。 -- **Verhoeff** 與 **Damm**:十進位方案,可抓出*所有*單一數字與相鄰換位錯誤(比 Luhn 更強)。 -- **ISO 7064 MOD 97-10**:IBAN 等使用的雙檢查碼方案。 +- **Luhn** (mod 10):信用卡、IMEI、多種國民身分碼——可抓出所有單一數字錯誤與多數相鄰換位。 +- **Verhoeff** 與 **Damm** :十進位方案,可抓出 *所有* 單一數字與相鄰換位錯誤(比 Luhn 更強)。 +- **ISO 7064 MOD 97-10** :IBAN 等使用的雙檢查碼方案。 每個方案提供 ``*_validate(number)``(含檢查碼的值是否驗證通過?)與 ``*_check_digit`` / ``*_check_digits`` (對裸負載應附加哪些檢查碼?)。非數字字元會被忽略,因此含空格/分組的輸入也適用。 diff --git a/docs/source/Zh/doc/new_features/v116_features_doc.rst b/docs/source/Zh/doc/new_features/v116_features_doc.rst index 8c779a5df..5cddbde75 100644 --- a/docs/source/Zh/doc/new_features/v116_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v116_features_doc.rst @@ -22,11 +22,11 @@ move_along_path([(100, 100), (400, 150), (400, 500)]) # 沿折線移動 drag_path([(50, 50), (300, 50), (300, 300)], button="mouse_left") # L 形拖曳 -``plan_path`` 以 ``per_segment_steps`` 個緩動步驟對每個相鄰點對插值(``easing`` 為 ``path_easings()`` 中任一 -名稱——``linear`` / ``ease_in_out_quad`` / ``ease_out_cubic`` / ``ease_in_cubic``),且不重複共用的交接點。 -``move_along_path`` 沿路徑發出移動事件;``drag_path`` 在第一個路徑點按下、移動經過整段路徑、在最後一點放開—— -用於多停靠點拖曳。兩者皆可傳入 ``sink`` 以供無頭測試。``drag_path`` 也接受 ``tween_drag`` 的節奏參數 -``step_delay_s``(每次移動後)與 ``settle_s``(按下前、按下後、放開前),而且和它一樣,任何一步丟出例外時會在 +``plan_path`` 以 ``per_segment_steps`` 個緩動步驟對每個相鄰點對插值( ``easing`` 為 ``path_easings()`` 中任一 +名稱—— ``linear`` / ``ease_in_out_quad`` / ``ease_out_cubic`` / ``ease_in_cubic`` ),且不重複共用的交接點。 +``move_along_path`` 沿路徑發出移動事件; ``drag_path`` 在第一個路徑點按下、移動經過整段路徑、在最後一點放開—— +用於多停靠點拖曳。兩者皆可傳入 ``sink`` 以供無頭測試。 ``drag_path`` 也接受 ``tween_drag`` 的節奏參數 +``step_delay_s`` (每次移動後)與 ``settle_s`` (按下前、按下後、放開前),而且和它一樣,任何一步丟出例外時會在 游標停下的位置放開按鍵。 執行器命令 diff --git a/docs/source/Zh/doc/new_features/v119_features_doc.rst b/docs/source/Zh/doc/new_features/v119_features_doc.rst index 10df75cfd..71bb220a5 100644 --- a/docs/source/Zh/doc/new_features/v119_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v119_features_doc.rst @@ -1,5 +1,5 @@ 按住按鍵 / 自動重複 -================== +=================== ``type_keyboard`` 是瞬間的按下+放開,``input_macro.run_sequence`` 雖可手動拼出按下 / 等待 / 放開,但先前沒有 「按住此鍵 N 秒」(遊戲移動、按住捲動)或「每秒送 R 次」(自動重複)的基本元件。 diff --git a/docs/source/Zh/doc/new_features/v11_features_doc.rst b/docs/source/Zh/doc/new_features/v11_features_doc.rst index a4fcbe185..1e45f1b99 100644 --- a/docs/source/Zh/doc/new_features/v11_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v11_features_doc.rst @@ -29,10 +29,10 @@ registry ``server.json`` 產生器,以及風險導向的測試選擇。 write_dataset(rows, "people.csv") # 或 .json -支援的欄位型別:``first_name``、``last_name``、``name``、``username``、 -``email``、``phone``、``city``、``company``、``word``、``sentence``、 -``uuid``、``bool``、``int``(min/max)、``float``(min/max/ndigits)、 -``choice``(choices)、``date``(start/end)。 +支援的欄位型別: ``first_name`` 、 ``last_name`` 、 ``name`` 、 ``username`` 、 +``email`` 、 ``phone`` 、 ``city`` 、 ``company`` 、 ``word`` 、 ``sentence`` 、 +``uuid`` 、 ``bool`` 、 ``int`` (min/max)、 ``float`` (min/max/ndigits)、 +``choice`` (choices)、 ``date`` (start/end)。 ``AC_generate_data`` 指令會寫出檔案(再交給 ``AC_load_data``)或直接 回傳資料列。 diff --git a/docs/source/Zh/doc/new_features/v125_features_doc.rst b/docs/source/Zh/doc/new_features/v125_features_doc.rst index 9e0a3a4a6..7606aac1e 100644 --- a/docs/source/Zh/doc/new_features/v125_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v125_features_doc.rst @@ -1,5 +1,5 @@ 表格 / 格線儲存格定位 -==================== +===================== ``anchor_locator`` 處理成對的空間關係(目標在錨點*附近* / *下方*),但無法定位二維格線——表格中「第 3 列、 第 2 欄的儲存格」。給定各儲存格的邊界框(來自影像或 OCR 列舉,例如 ``locate_all_image`` / ``find_text_matches``), diff --git a/docs/source/Zh/doc/new_features/v129_features_doc.rst b/docs/source/Zh/doc/new_features/v129_features_doc.rst index 08afe0718..6f727c463 100644 --- a/docs/source/Zh/doc/new_features/v129_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v129_features_doc.rst @@ -1,9 +1,9 @@ 遮罩模板比對(忽略背景) ======================== -一般模板比對會計分模板的*每個*像素,因此從某背景裁切出的圖示無法比對到同一圖示在不同背景上的情形—— -工具列圖示在 hover 與閒置按鈕上、游標疊在任意內容上、Logo 在主題化表面上。``match_masked`` 只計算你標記為 -相關的像素:明確的灰階 ``mask``(非零 = 使用),或——若傳入 RGBA 模板——其 alpha 通道。透明 /「不在乎」的 +一般模板比對會計分模板的 *每個* 像素,因此從某背景裁切出的圖示無法比對到同一圖示在不同背景上的情形—— +工具列圖示在 hover 與閒置按鈕上、游標疊在任意內容上、Logo 在主題化表面上。 ``match_masked`` 只計算你標記為 +相關的像素:明確的灰階 ``mask`` (非零 = 使用),或——若傳入 RGBA 模板——其 alpha 通道。透明 /「不在乎」的 像素就不會再把分數拉低。 它沿用與 :doc:`v127_features_doc` 相同的 ``Match`` 結果(左上角、尺寸、``score``、``center``),並在可注入的 diff --git a/docs/source/Zh/doc/new_features/v13_features_doc.rst b/docs/source/Zh/doc/new_features/v13_features_doc.rst index 701a6e788..9aab6b799 100644 --- a/docs/source/Zh/doc/new_features/v13_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v13_features_doc.rst @@ -55,7 +55,7 @@ Prompt-injection 防禦閘 A2A agent card ============== -A2A 協定讓 agent 之間透過 *Agent Card*(一份描述身分、端點與技能的 JSON +A2A 協定讓 agent 之間透過 *Agent Card* (一份描述身分、端點與技能的 JSON 文件)互相發現。發佈一份即可讓其他 agent 把 AutoControl 當成 GUI 自動化 夥伴來呼叫:: diff --git a/docs/source/Zh/doc/new_features/v144_features_doc.rst b/docs/source/Zh/doc/new_features/v144_features_doc.rst index a2da9610b..a377a3878 100644 --- a/docs/source/Zh/doc/new_features/v144_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v144_features_doc.rst @@ -1,9 +1,9 @@ 豐富剪貼簿——HTML(CF_HTML) ============================= -基礎 ``clipboard`` 模組只處理純文字(``CF_UNICODETEXT``)與影像(``CF_DIB``)。將*格式化*內容貼進 Word / +基礎 ``clipboard`` 模組只處理純文字( ``CF_UNICODETEXT`` )與影像( ``CF_DIB`` )。將 *格式化* 內容貼進 Word / Outlook / 富文字編輯器需要 ``CF_HTML`` 格式,其 ``Version / StartHTML / EndHTML / StartFragment / -EndFragment`` **位元組偏移**標頭以手寫極易出錯。``build_cf_html`` / ``parse_cf_html`` 以純 Python 計算與還原 +EndFragment`` **位元組偏移** 標頭以手寫極易出錯。 ``build_cf_html`` / ``parse_cf_html`` 以純 Python 計算與還原 該標頭(完整單元測試的往返,且在多位元組 UTF-8 下正確),而 ``set_clipboard_html`` / ``get_clipboard_html`` 將其包裝於 Win32 剪貼簿之上。 diff --git a/docs/source/Zh/doc/new_features/v145_features_doc.rst b/docs/source/Zh/doc/new_features/v145_features_doc.rst index 03c56aad0..422fcd992 100644 --- a/docs/source/Zh/doc/new_features/v145_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v145_features_doc.rst @@ -22,11 +22,11 @@ score = compare_histograms(baseline, image_histogram()) # 1.0 == 完全相同 -``image_histogram`` 回傳逐通道正規化直方圖的平面清單(``space`` = ``hsv`` / ``rgb`` / ``gray``;每通道貢獻 -``bins`` 個值)。``compare_histograms`` 支援 ``correlation`` / ``chisqr`` / ``intersection`` / -``bhattacharyya``(correlation / intersection 越高越相似;距離方法越高越不同)。``intersection`` 正規化到 -``0..1``(1 = 相同),同一個門檻適用任何 bin 數。``histogram_changed`` 比較 -``reference`` 與 ``current``(預設為螢幕)並回傳布林值,會依相似 vs 距離方法自動翻轉門檻比較方向。 +``image_histogram`` 回傳逐通道正規化直方圖的平面清單( ``space`` = ``hsv`` / ``rgb`` / ``gray`` ;每通道貢獻 +``bins`` 個值)。 ``compare_histograms`` 支援 ``correlation`` / ``chisqr`` / ``intersection`` / +``bhattacharyya`` (correlation / intersection 越高越相似;距離方法越高越不同)。 ``intersection`` 正規化到 +``0..1`` (1 = 相同),同一個門檻適用任何 bin 數。 ``histogram_changed`` 比較 +``reference`` 與 ``current`` (預設為螢幕)並回傳布林值,會依相似 vs 距離方法自動翻轉門檻比較方向。 執行器命令 ---------- diff --git a/docs/source/Zh/doc/new_features/v14_features_doc.rst b/docs/source/Zh/doc/new_features/v14_features_doc.rst index cecd70a44..a915eff2f 100644 --- a/docs/source/Zh/doc/new_features/v14_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v14_features_doc.rst @@ -6,8 +6,8 @@ Office 文件的 headless 讀寫——Excel(``.xlsx``)、Word(``.docx``)、 PowerPoint(``.pptx``)——讓流程不必驅動 GUI 就能吃進資料列或產出報表。 走完整五層(facade、``AC_*`` 執行器指令、MCP 工具、Script Builder)。 -背後的函式庫(``openpyxl`` / ``python-docx`` / ``python-pptx``)是 -**可選**相依:: +背後的函式庫( ``openpyxl`` / ``python-docx`` / ``python-pptx`` )是 +**可選** 相依:: pip install je_auto_control[office] diff --git a/docs/source/Zh/doc/new_features/v154_features_doc.rst b/docs/source/Zh/doc/new_features/v154_features_doc.rst index a23fe9a52..b5f477437 100644 --- a/docs/source/Zh/doc/new_features/v154_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v154_features_doc.rst @@ -34,5 +34,5 @@ result}`` JSONL,加上決定性的重播驅動器。 執行器命令 ---------- -``AC_replay_trace`` 透過執行器執行每一步的 ``action``(AC 動作清單)來重播 ``trace``(JSON 陣列或 JSONL),回傳 -``{count, results}``。它以 MCP 工具 ``ac_replay_trace``(有副作用)以及 Script Builder 中 **Flow** 分類下的命令提供。 +``AC_replay_trace`` 透過執行器執行每一步的 ``action`` (AC 動作清單)來重播 ``trace`` (JSON 陣列或 JSONL),回傳 +``{count, results}`` 。它以 MCP 工具 ``ac_replay_trace`` (有副作用)以及 Script Builder 中 **Flow** 分類下的命令提供。 diff --git a/docs/source/Zh/doc/new_features/v157_features_doc.rst b/docs/source/Zh/doc/new_features/v157_features_doc.rst index 02308f750..3c3eeb449 100644 --- a/docs/source/Zh/doc/new_features/v157_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v157_features_doc.rst @@ -35,6 +35,6 @@ UPC-A / Code-128)的功能——這些正是商品、庫存標籤與物流面 執行器指令 ---------- -``AC_read_barcodes``(``source`` / ``region`` → ``{count, barcodes}``)以 MCP 工具 -``ac_read_barcodes``(唯讀)及 Script Builder 指令 **Read Barcodes (1-D)**(位於 +``AC_read_barcodes`` ( ``source`` / ``region`` → ``{count, barcodes}`` )以 MCP 工具 +``ac_read_barcodes`` (唯讀)及 Script Builder 指令 **Read Barcodes (1-D)** (位於 **OCR** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v158_features_doc.rst b/docs/source/Zh/doc/new_features/v158_features_doc.rst index 11a4dbdfa..a64b60e71 100644 --- a/docs/source/Zh/doc/new_features/v158_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v158_features_doc.rst @@ -36,9 +36,9 @@ OpenCV 的 ``matchTemplate`` 不具旋轉不變性,因此略為傾斜的控制 執行器指令 ---------- -``AC_match_rotated``(``template`` / ``min_score`` / ``angles`` / ``scales`` / -``region`` / ``method`` → ``{found, match}``)與 ``AC_match_rotated_all``(另加 -``max_results`` / ``nms_iou`` → ``{count, matches}``)。兩者以 MCP 工具 -``ac_match_rotated`` / ``ac_match_rotated_all``(唯讀)及 Script Builder 指令 -**Match Template (rotated)** / **Match Template All (rotated)**(位於 **Image** +``AC_match_rotated`` ( ``template`` / ``min_score`` / ``angles`` / ``scales`` / +``region`` / ``method`` → ``{found, match}`` )與 ``AC_match_rotated_all`` (另加 +``max_results`` / ``nms_iou`` → ``{count, matches}`` )。兩者以 MCP 工具 +``ac_match_rotated`` / ``ac_match_rotated_all`` (唯讀)及 Script Builder 指令 +**Match Template (rotated)** / **Match Template All (rotated)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v159_features_doc.rst b/docs/source/Zh/doc/new_features/v159_features_doc.rst index 566c48950..a1ef8315d 100644 --- a/docs/source/Zh/doc/new_features/v159_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v159_features_doc.rst @@ -38,8 +38,8 @@ 執行器指令 ---------- -``AC_grid_cells``(``rows`` / ``cols`` / ``region`` → ``{count, cells}``)、 -``AC_cell_for_point``(``x`` / ``y`` / ``rows`` / ``cols`` / ``region`` → -``{found, cell}``)與 ``AC_point_for_cell``(``label`` / ``rows`` / ``cols`` / -``region`` → ``{point}``)。三者以 MCP 工具 ``ac_grid_cells`` / ``ac_cell_for_point`` / -``ac_point_for_cell``(唯讀)及 Script Builder 指令(位於 **Image** 分類下)形式提供。 +``AC_grid_cells`` ( ``rows`` / ``cols`` / ``region`` → ``{count, cells}`` )、 +``AC_cell_for_point`` ( ``x`` / ``y`` / ``rows`` / ``cols`` / ``region`` → +``{found, cell}`` )與 ``AC_point_for_cell`` ( ``label`` / ``rows`` / ``cols`` / +``region`` → ``{point}`` )。三者以 MCP 工具 ``ac_grid_cells`` / ``ac_cell_for_point`` / +``ac_point_for_cell`` (唯讀)及 Script Builder 指令(位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v160_features_doc.rst b/docs/source/Zh/doc/new_features/v160_features_doc.rst index 375ad1051..f0e7ea3c2 100644 --- a/docs/source/Zh/doc/new_features/v160_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v160_features_doc.rst @@ -1,11 +1,11 @@ 剪貼簿檔案拖放清單(CF_HDROP) ============================== -剪貼簿層原本能承載文字與影像,``rich_clipboard`` 又加入了 HTML,但框架始終無法把一份 -*檔案清單*放上剪貼簿——也就是當你複製檔案後在他處 ``Ctrl+V`` 進行真正的檔案複製時, +剪貼簿層原本能承載文字與影像, ``rich_clipboard`` 又加入了 HTML,但框架始終無法把一份 +*檔案清單* 放上剪貼簿——也就是當你複製檔案後在他處 ``Ctrl+V`` 進行真正的檔案複製時, Explorer 讀取的 ``CF_HDROP`` 內容。建構這個位元組區塊相當瑣碎:一個固定 20 位元組的 ``DROPFILES`` 標頭,後接以雙重 null 結尾(預設 UTF-16)的路徑清單,且標頭的 ``pFiles`` -位移需指向該清單。``clipboard_files`` 將這段容易出錯的封裝獨立出來。 +位移需指向該清單。 ``clipboard_files`` 將這段容易出錯的封裝獨立出來。 封裝邏輯位於純粹、可完整單元測試的 ``build_dropfiles`` / ``parse_dropfiles`` 位元組函式 (不需裝置、任何平台皆可),其上再疊加僅限 Windows 的 ``set_clipboard_files`` / diff --git a/docs/source/Zh/doc/new_features/v161_features_doc.rst b/docs/source/Zh/doc/new_features/v161_features_doc.rst index f24596f2a..fb69ec096 100644 --- a/docs/source/Zh/doc/new_features/v161_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v161_features_doc.rst @@ -36,6 +36,6 @@ 執行器指令 ---------- -``AC_match_with_trust``(``template`` / ``min_score`` / ``scales`` / ``ambiguous_ratio`` / -``region`` / ``method`` → ``{found, match}``)以 MCP 工具 ``ac_match_with_trust``(唯讀)及 -Script Builder 指令 **Match Template (trust-scored)**(位於 **Image** 分類下)形式提供。 +``AC_match_with_trust`` ( ``template`` / ``min_score`` / ``scales`` / ``ambiguous_ratio`` / +``region`` / ``method`` → ``{found, match}`` )以 MCP 工具 ``ac_match_with_trust`` (唯讀)及 +Script Builder 指令 **Match Template (trust-scored)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v162_features_doc.rst b/docs/source/Zh/doc/new_features/v162_features_doc.rst index 4feb92a7f..a5aa4fe47 100644 --- a/docs/source/Zh/doc/new_features/v162_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v162_features_doc.rst @@ -39,6 +39,6 @@ 執行器指令 ---------- -``AC_populate_table``(``grid`` / ``text_boxes`` / ``overlap`` → ``{n_rows, n_cols, cells, -spans}``)以 MCP 工具 ``ac_populate_table``(唯讀)及 Script Builder 指令 -**Fill Table From Grid + OCR**(位於 **OCR** 分類下)形式提供。 +``AC_populate_table`` ( ``grid`` / ``text_boxes`` / ``overlap`` → ``{n_rows, n_cols, cells, +spans}`` )以 MCP 工具 ``ac_populate_table`` (唯讀)及 Script Builder 指令 +**Fill Table From Grid + OCR** (位於 **OCR** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v163_features_doc.rst b/docs/source/Zh/doc/new_features/v163_features_doc.rst index 58eb411b6..d182917b5 100644 --- a/docs/source/Zh/doc/new_features/v163_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v163_features_doc.rst @@ -38,7 +38,7 @@ 執行器指令 ---------- -``AC_delta_observation``(``prev`` / ``curr`` / ``viewport`` / ``max_elements`` / ``max_lines`` -/ ``interactive_only`` → ``{summary, added, removed, changed}``)以 MCP 工具 -``ac_delta_observation``(唯讀)及 Script Builder 指令 **Observation: Delta (what changed)** +``AC_delta_observation`` ( ``prev`` / ``curr`` / ``viewport`` / ``max_elements`` / ``max_lines`` +/ ``interactive_only`` → ``{summary, added, removed, changed}`` )以 MCP 工具 +``ac_delta_observation`` (唯讀)及 Script Builder 指令 **Observation: Delta (what changed)** (位於 **Native UI** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v164_features_doc.rst b/docs/source/Zh/doc/new_features/v164_features_doc.rst index ca033afef..cb93a8912 100644 --- a/docs/source/Zh/doc/new_features/v164_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v164_features_doc.rst @@ -36,8 +36,8 @@ Otsu 法,找出「背景相關」團與「真正匹配」團之間的谷,並回 執行器指令 ---------- -``AC_match_auto``(``template`` / ``floor`` / ``max_results`` / ``region`` / ``method`` → -``{count, matches}``)與 ``AC_auto_threshold``(``template`` / ``region`` / ``method`` → -``{found, info}``)。兩者以 MCP 工具 ``ac_match_auto`` / ``ac_auto_threshold``(唯讀)及 +``AC_match_auto`` ( ``template`` / ``floor`` / ``max_results`` / ``region`` / ``method`` → +``{count, matches}`` )與 ``AC_auto_threshold`` ( ``template`` / ``region`` / ``method`` → +``{found, info}`` )。兩者以 MCP 工具 ``ac_match_auto`` / ``ac_auto_threshold`` (唯讀)及 Script Builder 指令 **Match Template (auto-threshold)** / **Auto Threshold (Otsu on scores)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v165_features_doc.rst b/docs/source/Zh/doc/new_features/v165_features_doc.rst index b51019718..f931127f0 100644 --- a/docs/source/Zh/doc/new_features/v165_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v165_features_doc.rst @@ -35,8 +35,8 @@ 執行器指令 ---------- -``AC_detect_borderless_table``(``boxes`` / ``page_width`` / ``min_gap`` / ``min_cols`` / -``min_rows`` → ``{found, table}``)與 ``AC_column_gutters``(``boxes`` / ``page_width`` / -``min_gap`` → ``{count, gutters}``)。兩者以 MCP 工具 ``ac_detect_borderless_table`` / -``ac_column_gutters``(唯讀)及 Script Builder 指令 **Detect Borderless Table** / -**Column Gutters (whitespace)**(位於 **OCR** 分類下)形式提供。 +``AC_detect_borderless_table`` ( ``boxes`` / ``page_width`` / ``min_gap`` / ``min_cols`` / +``min_rows`` → ``{found, table}`` )與 ``AC_column_gutters`` ( ``boxes`` / ``page_width`` / +``min_gap`` → ``{count, gutters}`` )。兩者以 MCP 工具 ``ac_detect_borderless_table`` / +``ac_column_gutters`` (唯讀)及 Script Builder 指令 **Detect Borderless Table** / +**Column Gutters (whitespace)** (位於 **OCR** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v166_features_doc.rst b/docs/source/Zh/doc/new_features/v166_features_doc.rst index 49b28fe1d..b1a4ba1f8 100644 --- a/docs/source/Zh/doc/new_features/v166_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v166_features_doc.rst @@ -27,15 +27,15 @@ state = checkbox_state(screenshot, checkbox_box) # "checked" | "unchecked" -``associate_fields`` 把文字以 ``:`` 結尾的框視為標籤,並在指定方向中(``max_gap`` 內)配到 -最近的值框,回傳 ``{label, value, direction, gap, label_box, value_box}``。 -``match_labels_to_widgets`` 依中心距離把每個 widget 配到最近標籤。``checkbox_state`` 由框內 -暗像素填充比例回傳 ``"checked"`` / ``"unchecked"``(image 可注入——路徑 / ndarray / PIL)。 +``associate_fields`` 把文字以 ``:`` 結尾的框視為標籤,並在指定方向中( ``max_gap`` 內)配到 +最近的值框,回傳 ``{label, value, direction, gap, label_box, value_box}`` 。 +``match_labels_to_widgets`` 依中心距離把每個 widget 配到最近標籤。 ``checkbox_state`` 由框內 +暗像素填充比例回傳 ``"checked"`` / ``"unchecked"`` (image 可注入——路徑 / ndarray / PIL)。 執行器指令 ---------- -``AC_associate_fields``(``text_boxes`` / ``directions`` / ``max_gap`` → ``{count, fields}``) -與 ``AC_match_labels_to_widgets``(``labels`` / ``widgets`` → ``{count, pairs}``)。兩者以 -MCP 工具 ``ac_associate_fields`` / ``ac_match_labels_to_widgets``(唯讀)及 Script Builder 指令 -**Associate Form Fields** / **Match Labels To Widgets**(位於 **OCR** 分類下)形式提供。 +``AC_associate_fields`` ( ``text_boxes`` / ``directions`` / ``max_gap`` → ``{count, fields}`` ) +與 ``AC_match_labels_to_widgets`` ( ``labels`` / ``widgets`` → ``{count, pairs}`` )。兩者以 +MCP 工具 ``ac_associate_fields`` / ``ac_match_labels_to_widgets`` (唯讀)及 Script Builder 指令 +**Associate Form Fields** / **Match Labels To Widgets** (位於 **OCR** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v167_features_doc.rst b/docs/source/Zh/doc/new_features/v167_features_doc.rst index f58751909..f4ec3c185 100644 --- a/docs/source/Zh/doc/new_features/v167_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v167_features_doc.rst @@ -7,8 +7,8 @@ 2–8 次);``actionability`` 純粹是*動作前*的閘門。``action_effect`` 補上這個迴圈:比對前後 觀測,並依動作的目標點分類結果,讓代理能立即反應。 -判定為下列之一:``no_op``(無變化)、``changed_near_target``(變化發生在我們動作之處——按鈕被 -按下)、``changed_elsewhere``(別處彈出意外對話框)、或 ``changed``(有變化但動作沒有可歸因的 +判定為下列之一: ``no_op`` (無變化)、 ``changed_near_target`` (變化發生在我們動作之處——按鈕被 +按下)、 ``changed_elsewhere`` (別處彈出意外對話框)、或 ``changed`` (有變化但動作沒有可歸因的 座標點)。 純標準函式庫,作用於元素字典 + 動作記錄;重用 ``element_diff.match_elements`` 做重疊配對與 @@ -38,8 +38,8 @@ 執行器指令 ---------- -``AC_classify_effect``(``before`` / ``after`` / ``action`` / ``radius`` → -``{effect, changed_near_target, changed_count, changed_centers, reason}``)與 -``AC_effect_near_point``(``before`` / ``after`` / ``point`` / ``radius`` → ``{near}``)。 -兩者以 MCP 工具 ``ac_classify_effect`` / ``ac_effect_near_point``(唯讀)及 Script Builder 指令 -**Classify Action Effect** / **Effect Near Point?**(位於 **Native UI** 分類下)形式提供。 +``AC_classify_effect`` ( ``before`` / ``after`` / ``action`` / ``radius`` → +``{effect, changed_near_target, changed_count, changed_centers, reason}`` )與 +``AC_effect_near_point`` ( ``before`` / ``after`` / ``point`` / ``radius`` → ``{near}`` )。 +兩者以 MCP 工具 ``ac_classify_effect`` / ``ac_effect_near_point`` (唯讀)及 Script Builder 指令 +**Classify Action Effect** / **Effect Near Point?** (位於 **Native UI** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v168_features_doc.rst b/docs/source/Zh/doc/new_features/v168_features_doc.rst index 405a7df7b..616cce0bf 100644 --- a/docs/source/Zh/doc/new_features/v168_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v168_features_doc.rst @@ -35,8 +35,8 @@ Canny 預設,因此不重複任何比對或幾何程式。``haystack`` 可注入 執行器指令 ---------- -``AC_edge_match``(``template`` / ``min_score`` / ``scales`` / ``region`` → -``{found, match}``)與 ``AC_edge_match_all``(另加 ``max_results`` / ``nms_iou`` → -``{count, matches}``)。兩者以 MCP 工具 ``ac_edge_match`` / ``ac_edge_match_all``(唯讀)及 +``AC_edge_match`` ( ``template`` / ``min_score`` / ``scales`` / ``region`` → +``{found, match}`` )與 ``AC_edge_match_all`` (另加 ``max_results`` / ``nms_iou`` → +``{count, matches}`` )。兩者以 MCP 工具 ``ac_edge_match`` / ``ac_edge_match_all`` (唯讀)及 Script Builder 指令 **Match Template (edge shape)** / **Match Template All (edge shape)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v169_features_doc.rst b/docs/source/Zh/doc/new_features/v169_features_doc.rst index 817236845..0bb7f78eb 100644 --- a/docs/source/Zh/doc/new_features/v169_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v169_features_doc.rst @@ -36,6 +36,6 @@ before 觀測做差異)評估,回傳逐子句的通過 / 失敗報告。 執行器指令 ---------- -``AC_check_postcondition``(``after`` / ``spec`` / ``before`` → ``{ok, clauses, failed}``) -以 MCP 工具 ``ac_check_postcondition``(唯讀)及 Script Builder 指令 **Check Postcondition** +``AC_check_postcondition`` ( ``after`` / ``spec`` / ``before`` → ``{ok, clauses, failed}`` ) +以 MCP 工具 ``ac_check_postcondition`` (唯讀)及 Script Builder 指令 **Check Postcondition** (位於 **Native UI** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v170_features_doc.rst b/docs/source/Zh/doc/new_features/v170_features_doc.rst index 7438d776a..d75ac5860 100644 --- a/docs/source/Zh/doc/new_features/v170_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v170_features_doc.rst @@ -1,11 +1,11 @@ 失敗 / 無效果動作的修復策略引擎 ================================ -當動作沒有效果或落點錯誤時,代理需要一個*策略*決定下一步該試什麼——重新定位重試、微調座標、 -把目標捲入視野、等待重試,或放棄並升級。``self_healing`` / ``locator_repair`` 只修復*無法 -解析*的定位器(找不到元素);當元素找到並點擊了卻無效果時,它們無能為力。``loop_guard`` 只 -*偵測*卡住的迴圈——沒有戰術選擇或退避。``step_repair`` 正是缺少的控制器:它消費一個效果判定 -(例如來自 ``action_effect``),並驅動有界的重試迴圈,每輪選擇下一個尚未嘗試的戰術。 +當動作沒有效果或落點錯誤時,代理需要一個 *策略* 決定下一步該試什麼——重新定位重試、微調座標、 +把目標捲入視野、等待重試,或放棄並升級。 ``self_healing`` / ``locator_repair`` 只修復 *無法 +解析* 的定位器(找不到元素);當元素找到並點擊了卻無效果時,它們無能為力。 ``loop_guard`` 只 +*偵測* 卡住的迴圈——沒有戰術選擇或退避。 ``step_repair`` 正是缺少的控制器:它消費一個效果判定 +(例如來自 ``action_effect`` ),並驅動有界的重試迴圈,每輪選擇下一個尚未嘗試的戰術。 純標準函式庫狀態機;每個副作用——執行動作、驗證、套用戰術、睡眠——都是注入的可呼叫物件, 因此迴圈完全確定性、可在無裝置下單元測試。不匯入 ``PySide6``。 diff --git a/docs/source/Zh/doc/new_features/v171_features_doc.rst b/docs/source/Zh/doc/new_features/v171_features_doc.rst index 627a47838..72861a39e 100644 --- a/docs/source/Zh/doc/new_features/v171_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v171_features_doc.rst @@ -33,6 +33,6 @@ 執行器指令 ---------- -``AC_match_subpixel``(``template`` / ``min_score`` / ``region`` / ``method`` → -``{found, match}``)以 MCP 工具 ``ac_match_subpixel``(唯讀)及 Script Builder 指令 -**Match Template (sub-pixel)**(位於 **Image** 分類下)形式提供。 +``AC_match_subpixel`` ( ``template`` / ``min_score`` / ``region`` / ``method`` → +``{found, match}`` )以 MCP 工具 ``ac_match_subpixel`` (唯讀)及 Script Builder 指令 +**Match Template (sub-pixel)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v172_features_doc.rst b/docs/source/Zh/doc/new_features/v172_features_doc.rst index eb4c5389d..c36f96f13 100644 --- a/docs/source/Zh/doc/new_features/v172_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v172_features_doc.rst @@ -35,8 +35,8 @@ Grounding 自我一致性(提案共識) 執行器指令 ---------- -``AC_consensus_point``(``candidates`` / ``cluster_radius`` → ``{found, result}``)與 -``AC_consensus_element``(``candidates`` / ``elements`` → ``{found, element, agreement}``)。 -兩者以 MCP 工具 ``ac_consensus_point`` / ``ac_consensus_element``(唯讀)及 Script Builder 指令 -**Grounding Consensus Point** / **Grounding Consensus Element**(位於 **Native UI** 分類下) +``AC_consensus_point`` ( ``candidates`` / ``cluster_radius`` → ``{found, result}`` )與 +``AC_consensus_element`` ( ``candidates`` / ``elements`` → ``{found, element, agreement}`` )。 +兩者以 MCP 工具 ``ac_consensus_point`` / ``ac_consensus_element`` (唯讀)及 Script Builder 指令 +**Grounding Consensus Point** / **Grounding Consensus Element** (位於 **Native UI** 分類下) 形式提供。 diff --git a/docs/source/Zh/doc/new_features/v174_features_doc.rst b/docs/source/Zh/doc/new_features/v174_features_doc.rst index b28279844..47033135a 100644 --- a/docs/source/Zh/doc/new_features/v174_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v174_features_doc.rst @@ -30,7 +30,7 @@ OCR 行的段落與清單分組 執行器指令 ---------- -``AC_group_paragraphs``(``lines`` / ``line_gap_factor`` → ``{count, paragraphs}``)與 -``AC_detect_lists``(``lines`` → ``{count, items}``)。兩者以 MCP 工具 ``ac_group_paragraphs`` / -``ac_detect_lists``(唯讀)及 Script Builder 指令 **Group Paragraphs** / **Detect Lists** +``AC_group_paragraphs`` ( ``lines`` / ``line_gap_factor`` → ``{count, paragraphs}`` )與 +``AC_detect_lists`` ( ``lines`` → ``{count, items}`` )。兩者以 MCP 工具 ``ac_group_paragraphs`` / +``ac_detect_lists`` (唯讀)及 Script Builder 指令 **Group Paragraphs** / **Detect Lists** (位於 **OCR** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v175_features_doc.rst b/docs/source/Zh/doc/new_features/v175_features_doc.rst index 346ef90be..74ddfe10f 100644 --- a/docs/source/Zh/doc/new_features/v175_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v175_features_doc.rst @@ -34,6 +34,6 @@ 執行器指令 ---------- -``AC_settle_point``(``churns`` / ``quiet_samples`` / ``max_churn`` → ``{settled, index}``) -以 MCP 工具 ``ac_settle_point``(唯讀)及 Script Builder 指令 **Settle Point (churn series)** +``AC_settle_point`` ( ``churns`` / ``quiet_samples`` / ``max_churn`` → ``{settled, index}`` ) +以 MCP 工具 ``ac_settle_point`` (唯讀)及 Script Builder 指令 **Settle Point (churn series)** (位於 **Flow** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v176_features_doc.rst b/docs/source/Zh/doc/new_features/v176_features_doc.rst index c37aecd54..89086ca14 100644 --- a/docs/source/Zh/doc/new_features/v176_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v176_features_doc.rst @@ -29,7 +29,7 @@ 執行器指令 ---------- -``AC_classify_lines``(``lines`` / ``heading_ratio`` → ``{count, lines}``)與 ``AC_outline`` -(``lines`` / ``heading_ratio`` → ``{count, headings}``)。兩者以 MCP 工具 ``ac_classify_lines`` / -``ac_outline``(唯讀)及 Script Builder 指令 **Classify Headings vs Body** / **Document Outline** +``AC_classify_lines`` ( ``lines`` / ``heading_ratio`` → ``{count, lines}`` )與 ``AC_outline`` +( ``lines`` / ``heading_ratio`` → ``{count, headings}`` )。兩者以 MCP 工具 ``ac_classify_lines`` / +``ac_outline`` (唯讀)及 Script Builder 指令 **Classify Headings vs Body** / **Document Outline** (位於 **OCR** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v177_features_doc.rst b/docs/source/Zh/doc/new_features/v177_features_doc.rst index af09a0c79..815c70be2 100644 --- a/docs/source/Zh/doc/new_features/v177_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v177_features_doc.rst @@ -2,10 +2,10 @@ ============================== 為代理的步驟評分需要把證據集中一處——動作是什麼、變了什麼、是否落在目標、宣告的後置條件 -是否成立。``trajectory_eval`` 對*已完成的整條軌跡*依靜態準則評分,沒有逐步證據; -``agent_trace`` 發出 OTel span(權杖 / 延遲),而非決策品質;``agent_replay`` 保存 -``{obs, action, result}`` 卻不評分。``critic_features`` 正是缺少的逐步層:它把 ``action_effect`` -(有無效果、落在何處)、``observation_delta``(變了多少)與 ``postcondition``(預期結果是否成立) +是否成立。 ``trajectory_eval`` 對 *已完成的整條軌跡* 依靜態準則評分,沒有逐步證據; +``agent_trace`` 發出 OTel span(權杖 / 延遲),而非決策品質; ``agent_replay`` 保存 +``{obs, action, result}`` 卻不評分。 ``critic_features`` 正是缺少的逐步層:它把 ``action_effect`` +(有無效果、落在何處)、 ``observation_delta`` (變了多少)與 ``postcondition`` (預期結果是否成立) 組合成單一精簡記錄,並附上確定性的規則式評分器,使此功能可完整無頭運作——把可選的 LLM-as-judge 留給整合者。 @@ -36,7 +36,7 @@ LLM-as-judge 留給整合者。 執行器指令 ---------- -``AC_build_critic_record``(``action`` / ``before`` / ``after`` / ``postcondition`` / -``radius`` → 該記錄)與 ``AC_score_step``(``record`` → ``{outcome, process_score, reasons}``)。 -兩者以 MCP 工具 ``ac_build_critic_record`` / ``ac_score_step``(唯讀)及 Script Builder 指令 -**Build Critic Record** / **Score Step (rule-based)**(位於 **Native UI** 分類下)形式提供。 +``AC_build_critic_record`` ( ``action`` / ``before`` / ``after`` / ``postcondition`` / +``radius`` → 該記錄)與 ``AC_score_step`` ( ``record`` → ``{outcome, process_score, reasons}`` )。 +兩者以 MCP 工具 ``ac_build_critic_record`` / ``ac_score_step`` (唯讀)及 Script Builder 指令 +**Build Critic Record** / **Score Step (rule-based)** (位於 **Native UI** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v178_features_doc.rst b/docs/source/Zh/doc/new_features/v178_features_doc.rst index b4d801e70..04aa99068 100644 --- a/docs/source/Zh/doc/new_features/v178_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v178_features_doc.rst @@ -1,9 +1,9 @@ 多模板共識比對 ============== -一個按鈕常以多種狀態呈現——預設 / 懸停 / 按下 / 停用——但它是單一邏輯目標。``ab_locator`` -A/B 測試*哪個單一策略勝出*,``match_template(scales=...)`` 跨縮放掃描*一個*模板——兩者都不把 -*多個參考裁切*融合成一次投票。``match_ensemble`` 比對每個參考,聚類命中中心,只有在至少 +一個按鈕常以多種狀態呈現——預設 / 懸停 / 按下 / 停用——但它是單一邏輯目標。 ``ab_locator`` +A/B 測試 *哪個單一策略勝出* , ``match_template(scales=...)`` 跨縮放掃描 *一個* 模板——兩者都不把 +*多個參考裁切* 融合成一次投票。 ``match_ensemble`` 比對每個參考,聚類命中中心,只有在至少 ``min_votes`` 個參考於 ``agree_px`` 內一致時才接受目標——大幅減少換膚 / 動畫 UI 上的誤判。 投票核心(``vote_centers``)為純標準函式庫,並重用 ``grounding_consensus`` 做聚類,因此可在 @@ -32,8 +32,8 @@ A/B 測試*哪個單一策略勝出*,``match_template(scales=...)`` 跨縮放掃 執行器指令 ---------- -``AC_match_ensemble``(``templates`` / ``min_score`` / ``agree_px`` / ``min_votes`` / -``region`` → ``{found, result}``)與 ``AC_vote_centers``(``centers`` / ``agree_px`` / -``min_votes`` → ``{found, result}``)。兩者以 MCP 工具 ``ac_match_ensemble`` / -``ac_vote_centers``(唯讀)及 Script Builder 指令 **Match Ensemble (vote references)** / -**Vote Centers (consensus)**(位於 **Image** 分類下)形式提供。 +``AC_match_ensemble`` ( ``templates`` / ``min_score`` / ``agree_px`` / ``min_votes`` / +``region`` → ``{found, result}`` )與 ``AC_vote_centers`` ( ``centers`` / ``agree_px`` / +``min_votes`` → ``{found, result}`` )。兩者以 MCP 工具 ``ac_match_ensemble`` / +``ac_vote_centers`` (唯讀)及 Script Builder 指令 **Match Ensemble (vote references)** / +**Vote Centers (consensus)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v179_features_doc.rst b/docs/source/Zh/doc/new_features/v179_features_doc.rst index f2cac8bd3..15f4d2b3f 100644 --- a/docs/source/Zh/doc/new_features/v179_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v179_features_doc.rst @@ -2,17 +2,17 @@ ======================== ``visual_match`` 的每個比對器都先轉灰階,因此形狀相同的紅色與綠色狀態指示燈對 -``match_template`` 而言*無法區分*——具辨別力的訊號被丟棄了。``color_region`` 找*已知*顏色的 -blob,卻無法以外觀對多色字形做樣板比對。``color_match`` 在 HSV 色相 / 飽和度通道上以色彩 -*距離*度量(平方距離,而非相關——相關會把絕對色相正規化掉,使紅→綠邊與黑→藍邊 +``match_template`` 而言 *無法區分* ——具辨別力的訊號被丟棄了。 ``color_region`` 找 *已知* 顏色的 +blob,卻無法以外觀對多色字形做樣板比對。 ``color_match`` 在 HSV 色相 / 飽和度通道上以色彩 +*距離* 度量(平方距離,而非相關——相關會把絕對色相正規化掉,使紅→綠邊與黑→藍邊 得分相同)進行比對,定位灰階比對會塌掉的色彩辨識目標。分數是 ``1 -`` 均方根距離,每個通道各以其 完整範圍正規化;色相沿色環取較短的一邊比較,所以色相 1 與 179 都是紅色。 本功能重用 ``color_region`` 的 RGB 載入器與 ``visual_match`` 的 resize / NMS / ``Match``。 ``haystack`` 可注入;搜尋可在合成陣列上單元測試。不匯入 ``PySide6``。 -註:*平坦*的單色 patch 會比對到每一塊同色區域——純色 blob 用 ``find_color_region`` 較合適; -``color_match`` 適用於有色彩*結構*的目標。 +註: *平坦* 的單色 patch 會比對到每一塊同色區域——純色 blob 用 ``find_color_region`` 較合適; +``color_match`` 適用於有色彩 *結構* 的目標。 無頭 API -------- @@ -36,8 +36,8 @@ blob,卻無法以外觀對多色字形做樣板比對。``color_match`` 在 HSV 執行器指令 ---------- -``AC_match_color``(``template`` / ``channels`` / ``min_score`` / ``scales`` / ``region`` → -``{found, match}``)與 ``AC_match_color_all``(另加 ``max_results`` / ``nms_iou`` → -``{count, matches}``)。兩者以 MCP 工具 ``ac_match_color`` / ``ac_match_color_all``(唯讀)及 +``AC_match_color`` ( ``template`` / ``channels`` / ``min_score`` / ``scales`` / ``region`` → +``{found, match}`` )與 ``AC_match_color_all`` (另加 ``max_results`` / ``nms_iou`` → +``{count, matches}`` )。兩者以 MCP 工具 ``ac_match_color`` / ``ac_match_color_all`` (唯讀)及 Script Builder 指令 **Match Template (colour/HSV)** / **Match Template All (colour/HSV)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v17_features_doc.rst b/docs/source/Zh/doc/new_features/v17_features_doc.rst index b44179159..ffa7ad705 100644 --- a/docs/source/Zh/doc/new_features/v17_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v17_features_doc.rst @@ -2,8 +2,8 @@ 新功能 (2026-06-19) — 反應式觀察器 ========================================== -非阻塞的**螢幕觀察器**:在區域/條件上註冊監看,當被監看的目標**出現**、 -**消失**或**改變**時,觸發回呼(或執行一段 action list)。這是阻塞式 +非阻塞的 **螢幕觀察器** :在區域/條件上註冊監看,當被監看的目標 **出現** 、 +**消失** 或 **改變** 時,觸發回呼(或執行一段 action list)。這是阻塞式 ``wait_for_*`` 的互補——流程可以在做其他事的同時對對話框、進度或狀態 變化做出反應(SikuliX 的 ``observe`` 模型)。 @@ -43,8 +43,8 @@ Python API 執行器 / MCP 指令 ================= -* ``AC_observe_add`` — 監看 ``kind``(``image`` / ``text`` / ``pixel``) - 的 ``event``,觸發時執行 ``actions``(watchdog 模式,推廣到螢幕內容)。 +* ``AC_observe_add`` — 監看 ``kind`` ( ``image`` / ``text`` / ``pixel`` ) + 的 ``event`` ,觸發時執行 ``actions`` (watchdog 模式,推廣到螢幕內容)。 * ``AC_observe_remove`` / ``AC_observe_list`` — 管理監看。 * ``AC_observe_poll`` — 評估每個監看一次並回傳觸發事件(決定性、免執行緒 ——適合腳本/測試)。 diff --git a/docs/source/Zh/doc/new_features/v180_features_doc.rst b/docs/source/Zh/doc/new_features/v180_features_doc.rst index f420a11a0..1b27492e0 100644 --- a/docs/source/Zh/doc/new_features/v180_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v180_features_doc.rst @@ -34,8 +34,8 @@ 執行器指令 ---------- -``AC_region_stability``(``frames`` / ``settle_threshold`` → ``{stable, mean_ssim, min_ssim}``) -與 ``AC_match_persistence``(``template`` / ``frames`` / ``min_score`` / ``agree_px`` → -``{persisted, n_hits, jitter}``)。兩者以 MCP 工具 ``ac_region_stability`` / -``ac_match_persistence``(唯讀)及 Script Builder 指令 **Region Stability (frames)** / -**Match Persistence (frames)**(位於 **Image** 分類下)形式提供。 +``AC_region_stability`` ( ``frames`` / ``settle_threshold`` → ``{stable, mean_ssim, min_ssim}`` ) +與 ``AC_match_persistence`` ( ``template`` / ``frames`` / ``min_score`` / ``agree_px`` → +``{persisted, n_hits, jitter}`` )。兩者以 MCP 工具 ``ac_region_stability`` / +``ac_match_persistence`` (唯讀)及 Script Builder 指令 **Region Stability (frames)** / +**Match Persistence (frames)** (位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v185_features_doc.rst b/docs/source/Zh/doc/new_features/v185_features_doc.rst index 824590c20..7b3734821 100644 --- a/docs/source/Zh/doc/new_features/v185_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v185_features_doc.rst @@ -4,10 +4,10 @@ ``rich_clipboard`` 已加入 ``CF_HTML`` 以便把豐富內容貼進 Word / Outlook,但仍缺少另外兩種 跨應用程式的剪貼簿格式: -* **RTF**(``"Rich Text Format"``)——幾乎每個豐富編輯器都接受、用於樣式貼上的格式。 +* **RTF** ( ``"Rich Text Format"`` )——幾乎每個豐富編輯器都接受、用於樣式貼上的格式。 ``build_rtf`` / ``rtf_to_text`` 以純 Python 建立與剝除 RTF 控制字與 ``\uNNNN`` / ``\'XX`` 轉義,並具備完全可單元測試的往返。 -* **CSV / TSV**(Excel 讀取的已註冊 ``"Csv"`` 格式)——``rows_to_csv`` / ``csv_to_rows`` 是對 +* **CSV / TSV** (Excel 讀取的已註冊 ``"Csv"`` 格式)—— ``rows_to_csv`` / ``csv_to_rows`` 是對 標準庫 ``csv`` 模組的薄包裝(可指定分隔符),讓表格能放上 / 讀下剪貼簿。 這些編解碼器與平台無關且可無頭測試;只有實際的剪貼簿 I/O 為 Win32(在其他平台拋出 diff --git a/docs/source/Zh/doc/new_features/v189_features_doc.rst b/docs/source/Zh/doc/new_features/v189_features_doc.rst index e38de4755..bff3cb199 100644 --- a/docs/source/Zh/doc/new_features/v189_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v189_features_doc.rst @@ -1,5 +1,5 @@ 顯示縮放 / 視覺 DPI 偵測 -======================= +======================== 在 100% 顯示縮放下裁切的模板,在 150% DPI 的機器上不會逐像素吻合——一切都放大了 1.5 倍。 ``visual_match.match_template`` *可以* 掃過多個縮放,但它只回傳單一最佳吻合的位置,並把各縮放的 diff --git a/docs/source/Zh/doc/new_features/v194_features_doc.rst b/docs/source/Zh/doc/new_features/v194_features_doc.rst index b87c1b2e0..51149728a 100644 --- a/docs/source/Zh/doc/new_features/v194_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v194_features_doc.rst @@ -1,8 +1,8 @@ 單次執行的步驟時間軸(瀑布圖 + 瓶頸步驟) ========================================== -動作 profiler 把計時按步驟*名稱*跨多次執行聚合——很適合「哪個動作平均較慢」,卻無助於「為什麼 -*這一次*執行很慢」。單次執行是一條有序時間軸:步驟 A 跑完、接著 B、再 C,其中某一步主導了時間。 +動作 profiler 把計時按步驟 *名稱* 跨多次執行聚合——很適合「哪個動作平均較慢」,卻無助於「為什麼 +*這一次* 執行很慢」。單次執行是一條有序時間軸:步驟 A 跑完、接著 B、再 C,其中某一步主導了時間。 ``step_timeline`` 把一次執行的步驟轉成瀑布圖(每步距起點的偏移、其時長、其占總時間的比例),並 排名瓶頸步驟,讓你能讀懂單一慢執行,而非平均值。 diff --git a/docs/source/Zh/doc/new_features/v201_features_doc.rst b/docs/source/Zh/doc/new_features/v201_features_doc.rst index 193be1287..46c08d17a 100644 --- a/docs/source/Zh/doc/new_features/v201_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v201_features_doc.rst @@ -29,8 +29,8 @@ backend 進行無頭測試;真正的 UIA 呼叫位於 Windows 後端。不匯入 set_view("Tiles", name="File List") # 切換檢視 控制項以 ``name`` / ``role`` / ``app_name`` / ``automation_id`` 定位(與其他原生控制動作相同)。 -``get_selection`` / ``list_views`` 回傳其字典(找不到控制項或模式則為 ``None``);``set_view`` -回傳 ``bool``(具名檢視不支援時為 False)。 +``get_selection`` / ``list_views`` 回傳其字典(找不到控制項或模式則為 ``None`` ); ``set_view`` +回傳 ``bool`` (具名檢視不支援時為 False)。 執行器指令 ---------- diff --git a/docs/source/Zh/doc/new_features/v207_features_doc.rst b/docs/source/Zh/doc/new_features/v207_features_doc.rst index 768b72135..7d34222c5 100644 --- a/docs/source/Zh/doc/new_features/v207_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v207_features_doc.rst @@ -1,5 +1,5 @@ 鎖定工作站 + 等待解鎖 -==================== +===================== :mod:`session_guard` 回答「目前 session 是否鎖定?」並在鎖定時丟出例外。缺少的另一半是對鎖定狀態 *採取行動*:在無人值守執行結束時鎖定機器、在恢復前阻塞直到有人解鎖,或把一連串鎖定狀態取樣化約為 diff --git a/docs/source/Zh/doc/new_features/v209_features_doc.rst b/docs/source/Zh/doc/new_features/v209_features_doc.rst index 6330793a7..b2c9c3326 100644 --- a/docs/source/Zh/doc/new_features/v209_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v209_features_doc.rst @@ -43,8 +43,8 @@ 執行器指令 ---------- -``AC_retry_delay``(``attempt`` / ``base`` / ``max_delay`` / ``multiplier`` / -``jitter`` → ``{delay}``)與 ``AC_plan_retry_delays``(``attempts`` … → -``{delays}``)暴露純退避排程(``jitter`` 預設為 ``none`` 以得確定結果)。皆以對應的唯讀 +``AC_retry_delay`` ( ``attempt`` / ``base`` / ``max_delay`` / ``multiplier`` / +``jitter`` → ``{delay}`` )與 ``AC_plan_retry_delays`` ( ``attempts`` … → +``{delays}`` )暴露純退避排程( ``jitter`` 預設為 ``none`` 以得確定結果)。皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。 -:func:`run_with_budget`(包裹一個 callable)則是 Python API 介面。 +:func:`run_with_budget` (包裹一個 callable)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v210_features_doc.rst b/docs/source/Zh/doc/new_features/v210_features_doc.rst index b62f6912d..3d96b0ea3 100644 --- a/docs/source/Zh/doc/new_features/v210_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v210_features_doc.rst @@ -42,8 +42,8 @@ 執行器指令 ---------- -``AC_compare_field_value``(``expected`` / ``actual`` / ``mode`` → ``{match, -mode, expected, actual}``,純函式)與 ``AC_verify_field_value``(``expected`` 加上 +``AC_compare_field_value`` ( ``expected`` / ``actual`` / ``mode`` → ``{match, +mode, expected, actual}`` ,純函式)與 ``AC_verify_field_value`` ( ``expected`` 加上 ``name`` / ``role`` / ``app_name`` / ``automation_id`` / ``mode`` → 比對結果, 透過無障礙後端讀取控制項的值)。皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令 -(位於 **Flow** 分類下)形式提供。:func:`fill_and_verify`(包裹一個輸入 callable)則是 Python API 介面。 +(位於 **Flow** 分類下)形式提供。 :func:`fill_and_verify` (包裹一個輸入 callable)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v212_features_doc.rst b/docs/source/Zh/doc/new_features/v212_features_doc.rst index 65bf8a344..eea2105aa 100644 --- a/docs/source/Zh/doc/new_features/v212_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v212_features_doc.rst @@ -1,9 +1,9 @@ 確保控制項處於目標狀態(冪等) ============================== -*無條件採取行動*的自動化——「點選核取方塊」、「輸入該值」——會把已勾選的方塊再次切換,或對已正確的 +*無條件採取行動* 的自動化——「點選核取方塊」、「輸入該值」——會把已勾選的方塊再次切換,或對已正確的 欄位重新輸入,且無法安全地重跑。穩健的型態是讀取-比較-行動-驗證:看目前狀態,若已相符就什麼都不做, -否則套用變更並確認生效。``ensure_state`` 正是此原語。 +否則套用變更並確認生效。 ``ensure_state`` 正是此原語。 * :func:`ensure_state` ——通用:透過 ``reader`` 讀取,若不等於 ``desired`` 就套用 ``setter`` 並 重讀,最多 ``attempts`` 次。 @@ -35,8 +35,8 @@ reader / setter / toggle 接縫皆可注入,故邏輯能在沒有真實控制項 執行器指令 ---------- -``AC_ensure_field_value``(``desired`` 加上 ``name`` / ``role`` / ``app_name`` / -``automation_id`` / ``attempts`` → ``{ok, changed, value, attempts}``)透過無障礙後端 +``AC_ensure_field_value`` ( ``desired`` 加上 ``name`` / ``role`` / ``app_name`` / +``automation_id`` / ``attempts`` → ``{ok, changed, value, attempts}`` )透過無障礙後端 冪等地設定原生控制項的值——先讀取,若已相符則不做任何事。以對應的 ``ac_ensure_field_value`` -MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。:func:`ensure_state` / -:func:`ensure_toggle`(接受任意 callable)則是 Python API 介面。 +MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。 :func:`ensure_state` / +:func:`ensure_toggle` (接受任意 callable)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v214_features_doc.rst b/docs/source/Zh/doc/new_features/v214_features_doc.rst index 708791f8a..91ab7b800 100644 --- a/docs/source/Zh/doc/new_features/v214_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v214_features_doc.rst @@ -1,15 +1,15 @@ 色覺辨認障礙模擬 + 碰撞檢查 -========================== +=========================== 狀態 UI 仰賴顏色——綠色「正常」對紅色「錯誤」的圓點、以顏色編碼的圖表圖例。對約 8% 有色覺辨認障礙 (CVD)的男性而言,這些可能難以分辨,而框架原本無從檢查。``cvd_simulate`` 補上無障礙 / 設計檢查 所需的兩個原語。 -* :func:`simulate_cvd` ——把 ``(r, g, b)`` 顏色透過二色覺模擬矩陣(``protanopia`` / - ``deuteranopia`` / ``tritanopia``)在給定 ``severity``(0 = 不受影響,1 = 完全二色覺)下映射。矩陣採 +* :func:`simulate_cvd` ——把 ``(r, g, b)`` 顏色透過二色覺模擬矩陣( ``protanopia`` / + ``deuteranopia`` / ``tritanopia`` )在給定 ``severity`` (0 = 不受影響,1 = 完全二色覺)下映射。矩陣採 Machado 等人(2009),在線性 RGB 中套用(先把 sRGB 線性化,套用後再編碼回去)。 * :func:`colors_collide` ——在某 CVD 類型下模擬兩個顏色,並回報它們是否變得太相似而難以區分 - (模擬後的感知 ``redmean`` 距離低於 ``threshold``)。 + (模擬後的感知 ``redmean`` 距離低於 ``threshold`` )。 * :func:`color_distance` ——底層的 ``redmean`` 色差度量。 純標準函式庫——不需 numpy / OpenCV——以單純的 RGB tuple 運作,故能完整測試。不匯入 ``PySide6``。 diff --git a/docs/source/Zh/doc/new_features/v216_features_doc.rst b/docs/source/Zh/doc/new_features/v216_features_doc.rst index 6b0c77bf6..164d997ab 100644 --- a/docs/source/Zh/doc/new_features/v216_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v216_features_doc.rst @@ -6,9 +6,9 @@ 把取樣區域拆成其主要前景(少數——通常是文字)與背景(多數)顏色,再評其 WCAG 對比。 * :func:`grade_contrast` ——純函式:把前景 / 背景配對對 WCAG 2.x 門檻轉為 - ``{ratio, aa, aaa, aa_large, aaa_large}``。 -* :func:`dominant_pair` ——純函式:依亮度把一串取樣 RGB 像素拆成主要的 ``{foreground, background}``。 -* :func:`region_contrast` ——取樣螢幕區域並評分,透過可注入的 ``sampler``(預設為真實螢幕擷取)。 + ``{ratio, aa, aaa, aa_large, aaa_large}`` 。 +* :func:`dominant_pair` ——純函式:依亮度把一串取樣 RGB 像素拆成主要的 ``{foreground, background}`` 。 +* :func:`region_contrast` ——取樣螢幕區域並評分,透過可注入的 ``sampler`` (預設為真實螢幕擷取)。 評分與拆分皆為純函式並重用 :func:`a11y_audit.contrast_ratio`,故能在沒有螢幕的情況下完整測試。 不匯入 ``PySide6``。 diff --git a/docs/source/Zh/doc/new_features/v217_features_doc.rst b/docs/source/Zh/doc/new_features/v217_features_doc.rst index 83c82f8cf..d3c89c39b 100644 --- a/docs/source/Zh/doc/new_features/v217_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v217_features_doc.rst @@ -35,7 +35,7 @@ 執行器指令 ---------- -``AC_match_theme``(``template`` 加上 ``region`` ``[x, y, w, h]`` / ``method`` / -``min_score`` → ``{found, x, y, width, height, score}``)跨主題切換定位模板。以對應的唯讀 +``AC_match_theme`` ( ``template`` 加上 ``region`` ``[x, y, w, h]`` / ``method`` / +``min_score`` → ``{found, x, y, width, height, score}`` )跨主題切換定位模板。以對應的唯讀 ``ac_match_theme`` MCP 工具及 Script Builder 指令(位於 **Image** 分類下)形式提供。 -:func:`normalize_theme`(回傳影像陣列)則是 Python API 介面。 +:func:`normalize_theme` (回傳影像陣列)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v219_features_doc.rst b/docs/source/Zh/doc/new_features/v219_features_doc.rst index a302cd222..745763655 100644 --- a/docs/source/Zh/doc/new_features/v219_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v219_features_doc.rst @@ -5,9 +5,9 @@ Set-of-Marks 與元素提案器回傳*方框*,卻不告訴你*每個方框是什 已能讀取一個已知是核取方塊的方框;缺少的是它之前的分類步驟——這個方框是核取方塊、單選鈕、按鈕、 文字欄位還是切換開關?``icon_classify`` 從低成本的幾何特徵(無需模型)回答此問題。 -* :func:`box_features` ——擷取方框區域的 ``{aspect, fill, edge_density, circularity, vertices}``(客觀量測)。 +* :func:`box_features` ——擷取方框區域的 ``{aspect, fill, edge_density, circularity, vertices}`` (客觀量測)。 * :func:`classify_widget` ——純函式:以記載的啟發式規則把特徵字典映射為控制項型別。 -* :func:`classify_icon` ——組合兩者:把一個方框轉為 ``{type, features}``。 +* :func:`classify_icon` ——組合兩者:把一個方框轉為 ``{type, features}`` 。 ``classify_widget`` 為純函式且可完整測試;``box_features`` 延遲匯入 cv2 / numpy(模組無需它們即可匯入), 並重用 :func:`visual_match._to_gray`。不匯入 ``PySide6``。 diff --git a/docs/source/Zh/doc/new_features/v221_features_doc.rst b/docs/source/Zh/doc/new_features/v221_features_doc.rst index 986ae7b9d..857c85881 100644 --- a/docs/source/Zh/doc/new_features/v221_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v221_features_doc.rst @@ -32,7 +32,7 @@ 執行器指令 ---------- -``AC_act_in_view``(``target`` 加上 ``kind`` / ``direction`` / ``max_scrolls`` / -``scroll_amount`` / ``button`` → ``{acted, coords, scrolls}``)把 template 或文字目標捲入畫面並點擊。 +``AC_act_in_view`` ( ``target`` 加上 ``kind`` / ``direction`` / ``max_scrolls`` / +``scroll_amount`` / ``button`` → ``{acted, coords, scrolls}`` )把 template 或文字目標捲入畫面並點擊。 以對應的 ``ac_act_in_view`` MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。 -:func:`act_in_view`(接受任意 action 與 actionability 探針)則是 Python API 介面。 +:func:`act_in_view` (接受任意 action 與 actionability 探針)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v222_features_doc.rst b/docs/source/Zh/doc/new_features/v222_features_doc.rst index 3a82d3d87..9c004d30a 100644 --- a/docs/source/Zh/doc/new_features/v222_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v222_features_doc.rst @@ -35,7 +35,7 @@ actionability 探針(``region_sampler`` / ``enabled_probe`` / ``hit_tester``)與 執行器指令 ---------- -``AC_act_with_mode``(``x`` / ``y`` 加上 ``mode`` / ``button`` → ``{mode, acted, -actionable, reason, point}``)以所選模式點擊一個點——``trial`` 是絕不點擊的乾跑探測,``force`` 無條件點擊。 +``AC_act_with_mode`` ( ``x`` / ``y`` 加上 ``mode`` / ``button`` → ``{mode, acted, +actionable, reason, point}`` )以所選模式點擊一個點—— ``trial`` 是絕不點擊的乾跑探測, ``force`` 無條件點擊。 以對應的 ``ac_act_with_mode`` MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。 -:func:`act_with_mode`(接受任意 action)則是 Python API 介面。 +:func:`act_with_mode` (接受任意 action)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v223_features_doc.rst b/docs/source/Zh/doc/new_features/v223_features_doc.rst index 0f4f9d7c3..32bbcc278 100644 --- a/docs/source/Zh/doc/new_features/v223_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v223_features_doc.rst @@ -27,9 +27,9 @@ Actions 選單 視窗左側依同樣五個分類列出每一個已註冊的分頁,不論是否已開啟;已開啟的以粗體 顯示。點一下就開啟該功能(已開啟則切到最前面)。搜尋框會隨輸入過濾清單——比對 -標題、鍵名(``usb_devices``)或分類——按 **Return** 開啟第一個符合的項目。 -``Ctrl+K`` (**View → Search Features...**) 從任何地方把游標移到搜尋框, -``Ctrl+B`` (**View → Navigation Panel**) 隱藏或顯示面板。 +標題、鍵名( ``usb_devices`` )或分類——按 **Return** 開啟第一個符合的項目。 +``Ctrl+K`` ( **View → Search Features...** ) 從任何地方把游標移到搜尋框, +``Ctrl+B`` ( **View → Navigation Panel** ) 隱藏或顯示面板。 分頁在第一次開啟時才建立:視窗啟動時只有三個預設分頁與主元件自己的表單,其他 分頁的模組要到開啟時才匯入。 diff --git a/docs/source/Zh/doc/new_features/v2_features_doc.rst b/docs/source/Zh/doc/new_features/v2_features_doc.rst index df7709db9..21c229b6f 100644 --- a/docs/source/Zh/doc/new_features/v2_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v2_features_doc.rst @@ -283,7 +283,7 @@ Wayland libei native backend 對 ``libei.so.*`` 的 ctypes 綁定,繞過 CLI shim 取得微秒級延遲。 以 ``JE_AUTOCONTROL_WAYLAND_INPUT_BACKEND=libei|cli|auto`` 啟用; -``auto``(預設)在 libei 可載入時用 libei,否則用 CLI,現有 +``auto`` (預設)在 libei 可載入時用 libei,否則用 CLI,現有 部署不會中斷。 @@ -374,10 +374,10 @@ helper(``je_auto_control.gui.flow_editor.layout_steps``)可單元 (規劃 → 執行 → 驗證 → 重試)開放給 JSON action 與 MCP。參數: * ``goal`` — 自然語言目標。 -* ``backend`` — ``"anthropic"`` 或 ``"openai"``。``AC_run_agent`` 預設只提供聚焦、低風險的 +* ``backend`` — ``"anthropic"`` 或 ``"openai"`` 。 ``AC_run_agent`` 預設只提供聚焦、低風險的 computer-use allow-list,不再把完整的 ``AC_*`` 命令目錄交給模型。需要自訂工具集時, 可直接用 ``export_anthropic_tools(only=[...])`` 或 ``export_openai_tools(only=[...])`` 建立 backend。 -* ``max_steps``(預設 25)、``wall_seconds``(預設 300.0)。 +* ``max_steps`` (預設 25)、 ``wall_seconds`` (預設 300.0)。 * ``model`` / ``max_tokens`` — backend 專屬覆寫。 每次向模型發出的請求 120 秒逾時,對話裡只重送最新的三張截圖(較早的換成一行文字), @@ -411,7 +411,7 @@ Anthropic 原生 Computer-Use 路徑(``computer_20251124``)仍透過 redacted_bytes, result = redact_png_bytes(png_bytes, policy=POLICY_STRICT) ``AC_redact_screenshot`` 與 ``ac_redact_screenshot`` 從磁碟讀取 -PNG、跑 engine、寫回 ``output_path``(未指定時覆蓋原檔),並回傳 +PNG、跑 engine、寫回 ``output_path`` (未指定時覆蓋原檔),並回傳 合併後的 bounding box list 供稽核。 diff --git a/docs/source/Zh/doc/new_features/v31_features_doc.rst b/docs/source/Zh/doc/new_features/v31_features_doc.rst index 3b40f558b..5e0bafbb5 100644 --- a/docs/source/Zh/doc/new_features/v31_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v31_features_doc.rst @@ -2,11 +2,11 @@ 新功能 (2026-06-19) — Plugin SDK ========================================== -第三方 pip 套件現在可透過 setuptools **entry point**(``je_auto_control.commands`` +第三方 pip 套件現在可透過 setuptools **entry point** ( ``je_auto_control.commands`` 群組)宣告式地註冊新的 ``AC_*`` 執行器指令——把單體變成生態系(pytest / Playwright 的成長方式)。AutoControl 於執行期探索它們;探索到的指令立即可 用於 JSON action 檔、socket server、排程器與 MCP。純標準庫 -(``importlib.metadata``);走完整五層。 +( ``importlib.metadata`` );走完整五層。 .. contents:: :local: diff --git a/docs/source/Zh/doc/new_features/v34_features_doc.rst b/docs/source/Zh/doc/new_features/v34_features_doc.rst index ccf1a4358..caa5463cc 100644 --- a/docs/source/Zh/doc/new_features/v34_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v34_features_doc.rst @@ -41,7 +41,7 @@ HTTP 用戶端可連線的主機。它會被每一次 指令 效果 ================================ =================================================== ``AC_egress_allow`` 將 HTTP 用戶端鎖定到 ``allow`` / ``deny`` 清單。 -``AC_egress_check`` 回報 URL 的 ``{allowed}``(不拋出例外)。 +``AC_egress_check`` 回報 URL 的 ``{allowed}`` (不拋出例外)。 ``AC_egress_reset`` 將策略清回 allow-all。 ================================ =================================================== diff --git a/docs/source/Zh/doc/new_features/v36_features_doc.rst b/docs/source/Zh/doc/new_features/v36_features_doc.rst index 5df6802a3..e8f6c98ea 100644 --- a/docs/source/Zh/doc/new_features/v36_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v36_features_doc.rst @@ -5,8 +5,8 @@ Agent 軌跡評估 徑?」。:func:`evaluate_trajectory` 依宣告式**評分標準(rubric)**為一次記錄的執行評 分,為 agent 回歸測試提供確定性、無相依的訊號。 -*軌跡(trajectory)*是該次執行所採取步驟的有序清單 —— 每步是一個至少含 ``"action"`` -名稱、可選含 ``"args"`` / ``"observation"`` 的 dict。*評分標準*為純資料(因此可自在地 +*軌跡(trajectory)* 是該次執行所採取步驟的有序清單 —— 每步是一個至少含 ``"action"`` +名稱、可選含 ``"args"`` / ``"observation"`` 的 dict。 *評分標準* 為純資料(因此可自在地 存於 JSON action 檔或經 MCP 傳入): ================================ =================================================== diff --git a/docs/source/Zh/doc/new_features/v37_features_doc.rst b/docs/source/Zh/doc/new_features/v37_features_doc.rst index ff2c15b47..e17c42373 100644 --- a/docs/source/Zh/doc/new_features/v37_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v37_features_doc.rst @@ -13,18 +13,46 @@ maker-checker 審批、密鑰掃描器、稽核記錄、CycloneDX SBOM。 對應的控制項 ------------ -================ ============= =================================================== ============================== -框架 控制項 標題 證據鍵 -================ ============= =================================================== ============================== -SOC2 CC6.1 邏輯存取限制於授權主機 ``egress_allowlist_enforced`` -SOC2 CC6.3 最小權限、限時憑證 ``jit_credentials_used`` -SOC2 CC6.8 密鑰不硬編碼且經掃描 ``secrets_scanned`` -SOC2 CC7.3 安全事件留存供審查 ``audit_logging_enabled`` -SOC2 CC8.1 變更需職責分離(maker-checker)審批 ``change_approval_required`` -ISO 27001 A.5.23 雲端/網路出口的資訊安全 ``egress_allowlist_enforced`` -ISO 27001 A.8.16 監控活動 / 稽核軌跡 ``audit_logging_enabled`` -ISO 27001 A.8.30 維護軟體物料清單 ``sbom_generated`` -================ ============= =================================================== ============================== +.. list-table:: + :header-rows: 1 + :widths: 12 12 46 30 + + * - 框架 + - 控制項 + - 標題 + - 證據鍵 + * - SOC2 + - CC6.1 + - 邏輯存取限制於授權主機 + - ``egress_allowlist_enforced`` + * - SOC2 + - CC6.3 + - 最小權限、限時憑證 + - ``jit_credentials_used`` + * - SOC2 + - CC6.8 + - 密鑰不硬編碼且經掃描 + - ``secrets_scanned`` + * - SOC2 + - CC7.3 + - 安全事件留存供審查 + - ``audit_logging_enabled`` + * - SOC2 + - CC8.1 + - 變更需職責分離(maker-checker)審批 + - ``change_approval_required`` + * - ISO 27001 + - A.5.23 + - 雲端/網路出口的資訊安全 + - ``egress_allowlist_enforced`` + * - ISO 27001 + - A.8.16 + - 監控活動 / 稽核軌跡 + - ``audit_logging_enabled`` + * - ISO 27001 + - A.8.30 + - 維護軟體物料清單 + - ``sbom_generated`` 無頭 API -------- diff --git a/docs/source/Zh/doc/new_features/v3_features_doc.rst b/docs/source/Zh/doc/new_features/v3_features_doc.rst index 994515499..4e62b637e 100644 --- a/docs/source/Zh/doc/new_features/v3_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v3_features_doc.rst @@ -71,7 +71,7 @@ ac_assert_http``。 斷言組合器(群組 / OR / 輪詢) ------------------------------ -用宣告式 *spec*(如 ``{"kind": "text", "text": "Saved"}`` 這樣的純 dict) +用宣告式 *spec* (如 ``{"kind": "text", "text": "Saved"}`` 這樣的純 dict) 組合八種斷言,讓相同檢查在 Python、JSON、MCP 中都能使用而不需傳遞 callable:: from je_auto_control import assert_all, assert_any, assert_eventually @@ -101,7 +101,7 @@ MCP:``ac_assert_all / ac_assert_any / ac_assert_eventually``。 媒體斷言(音訊 / 影片) ----------------------- +----------------------- 斷言某個東西確實「播放」或「動」了:: @@ -270,7 +270,7 @@ MCP:``ac_audit_*``。GUI:**A11y Audit** 分頁。 行動裝置矩陣 ============ -將單一 action list **並行**分發到多台 Android / iOS 裝置,每台使用各自獨立的 +將單一 action list **並行** 分發到多台 Android / iOS 裝置,每台使用各自獨立的 executor(執行緒間的執行期變數作用域互不衝突)。腳本透過綁定的 ``${device.*}`` 變數鎖定當前裝置:: diff --git a/docs/source/Zh/doc/new_features/v40_features_doc.rst b/docs/source/Zh/doc/new_features/v40_features_doc.rst index b0969a691..c8b07ecad 100644 --- a/docs/source/Zh/doc/new_features/v40_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v40_features_doc.rst @@ -39,9 +39,9 @@ Indel 比例 ``2 * LCS / (len(a) + len(b))``。若安裝了 ================================ =================================================== 指令 效果 ================================ =================================================== -``AC_fuzzy_ratio`` 兩字串相似度的 ``{score}``。 -``AC_fuzzy_best_match`` 從候選中取 ``{match, score, index}``(或 null)。 -``AC_fuzzy_dedupe`` 收合近似重複後的 ``{unique}``。 +``AC_fuzzy_ratio`` 兩字串相似度的 ``{score}`` 。 +``AC_fuzzy_best_match`` 從候選中取 ``{match, score, index}`` (或 null)。 +``AC_fuzzy_dedupe`` 收合近似重複後的 ``{unique}`` 。 ================================ =================================================== ``choices`` / ``items`` 接受清單或 JSON 字串清單(因此視覺化建構器可用)。相同操作亦提供 diff --git a/docs/source/Zh/doc/new_features/v42_features_doc.rst b/docs/source/Zh/doc/new_features/v42_features_doc.rst index fe37c8d68..920443a27 100644 --- a/docs/source/Zh/doc/new_features/v42_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v42_features_doc.rst @@ -5,8 +5,8 @@ 將視覺上相似的影像對應到數值接近的指紋,因此可依漢明距離分群並收合 —— 每個明顯不同的 畫面只保留一個代表。 -雜湊函式使用 **Pillow**(已是核心相依 —— 無需額外套件);去重/比較邏輯為純 Python,且 -``hasher`` 可注入,因此分群可在無任何影像下單元測試。不匯入 ``PySide6``。 +雜湊函式使用 **Pillow** (已是核心相依 —— 無需額外套件);去重/比較邏輯為純 Python,且 +``hasher`` 可注入,因此分群可在無任何影像下單元測試。不匯入 ``PySide6`` 。 無頭 API -------- diff --git a/docs/source/Zh/doc/new_features/v48_features_doc.rst b/docs/source/Zh/doc/new_features/v48_features_doc.rst index 85b5bec6f..b69f942c8 100644 --- a/docs/source/Zh/doc/new_features/v48_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v48_features_doc.rst @@ -43,7 +43,7 @@ no / on / off / 1 / 0 之一。 ================================ =================================================== ``AC_set_asset`` 儲存具型別、依環境的資產。 ``AC_get_asset`` 讀取資產(credential 維持參照)。 -``AC_list_assets`` 列出 ``{name, type, environment}``(不含值)。 +``AC_list_assets`` 列出 ``{name, type, environment}`` (不含值)。 ================================ =================================================== credential 的**解析**刻意僅限 Python API(因此密鑰永不進入執行紀錄)。相同的生命週期操 diff --git a/docs/source/Zh/doc/new_features/v4_features_doc.rst b/docs/source/Zh/doc/new_features/v4_features_doc.rst index 201592a29..6ce54446c 100644 --- a/docs/source/Zh/doc/new_features/v4_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v4_features_doc.rst @@ -165,8 +165,8 @@ GUI === * **錄製編輯器 Undo** — 每次編輯(刪步驟、trim、rescale、調整延遲、 - filter)都會快照進 undo stack;**Ctrl+Z** 與 Undo 按鈕可還原前一狀態。 + filter)都會快照進 undo stack; **Ctrl+Z** 與 Undo 按鈕可還原前一狀態。 * **觸發器分頁** — *Combine selected* 把選取的觸發器包成 AllOf / AnyOf / Sequence 複合觸發器;新增 **Cron** 觸發器型別。 -* **斷言分頁** — 新增 **VLM**(「畫面符合描述」)斷言型別。 +* **斷言分頁** — 新增 **VLM** (「畫面符合描述」)斷言型別。 * 每個新的 ``AC_*`` 指令都可在視覺化 **Script Builder** 中建構。 diff --git a/docs/source/Zh/doc/new_features/v51_features_doc.rst b/docs/source/Zh/doc/new_features/v51_features_doc.rst index caed69521..080e038c0 100644 --- a/docs/source/Zh/doc/new_features/v51_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v51_features_doc.rst @@ -4,16 +4,24 @@ JSONPath 查詢 執行器內建的路徑走訪只會以 ``.`` 切分並索引 —— 無法做萬用字元、遞迴下降或過濾,因此含 陣列的 API/DB 回應很難擷取。``json_query`` 在已解析的 JSON 上加入聚焦的 JSONPath 子集: -================ =================================================== -語法 意義 -================ =================================================== -``$`` 根(可省略前綴) -``.name`` 成員存取 -``[n]`` ``[-n]`` 串列索引(負數由尾端起算) -``*`` ``[*]`` 萬用字元(所有成員 / 元素) -``..`` 遞迴下降 -``[?(@.k op v)]`` 過濾陣列元素(``op`` ∈ ``== != < <= > >=``) -================ =================================================== +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - 語法 + - 意義 + * - ``$`` + - 根(可省略前綴) + * - ``.name`` + - 成員存取 + * - ``[n]`` ``[-n]`` + - 串列索引(負數由尾端起算) + * - ``*`` ``[*]`` + - 萬用字元(所有成員 / 元素) + * - ``..`` + - 遞迴下降 + * - ``[?(@.k op v)]`` + - 過濾陣列元素(``op`` ∈ ``== != < <= > >=``) 過濾條件的欄位可以是巢狀的(``@.a.b``),``[?(@.k)]`` 保留有 ``k`` 的元素;對物件套用過濾時,挑的是它的成員值。 比較的值是 JSON 數字、加引號的字串、``true``、``false`` 或 ``null``。 diff --git a/docs/source/Zh/doc/new_features/v55_features_doc.rst b/docs/source/Zh/doc/new_features/v55_features_doc.rst index 67fcc3d73..cbe1eb699 100644 --- a/docs/source/Zh/doc/new_features/v55_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v55_features_doc.rst @@ -1,8 +1,8 @@ 文字 PII 偵測與遮蔽 -================== +=================== 影像遮蔽模組會在螢幕截圖中模糊 PII,但從 UI、OCR、剪貼簿、LLM 提示/回應或日誌行擷取的 -*文字*卻沒有字串層級的對應 —— 因此 PII 可能洩漏進動作紀錄、稽核日誌或一次模型呼叫。 +*文字* 卻沒有字串層級的對應 —— 因此 PII 可能洩漏進動作紀錄、稽核日誌或一次模型呼叫。 ``detect_pii`` / ``redact_pii_text`` 可在純文字上找出並遮蔽電子郵件、電話號碼、SSN、信 用卡號(以 Luhn 驗證)、IPv4 位址與 IBAN(連寫或四碼一組的列印格式,以 mod-97 驗證)。 diff --git a/docs/source/Zh/doc/new_features/v58_features_doc.rst b/docs/source/Zh/doc/new_features/v58_features_doc.rst index 504ffc077..825dc79c2 100644 --- a/docs/source/Zh/doc/new_features/v58_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v58_features_doc.rst @@ -36,10 +36,10 @@ SARIF 匯出器,供 GitHub / Azure DevOps 程式碼掃描使用。 tool_name="AutoControl-VulnScan") ``is_affected(version, osv_range)`` 透過掃描 ``introduced`` / ``fixed`` / -``last_affected`` 事件來評估單一 OSV 範圍;``match_package`` 以資料庫比對單一套件(明確的 -``versions`` **或**範圍);``scan_components`` 跑完整份 SBOM。套件名稱以 PEP-503 正規化後比較, -OSV 嚴重度字詞(``CRITICAL`` / ``HIGH`` / ``MODERATE`` / ``LOW``)對應到 SARIF 的 -``error`` / ``warning`` / ``note`` 等級(預設為 ``warning``)。 +``last_affected`` 事件來評估單一 OSV 範圍; ``match_package`` 以資料庫比對單一套件(明確的 +``versions`` **或** 範圍); ``scan_components`` 跑完整份 SBOM。套件名稱以 PEP-503 正規化後比較, +OSV 嚴重度字詞( ``CRITICAL`` / ``HIGH`` / ``MODERATE`` / ``LOW`` )對應到 SARIF 的 +``error`` / ``warning`` / ``note`` 等級(預設為 ``warning`` )。 版本排序採務實的數值比較:release 元件以整數比較,pre-release 後綴(``1.2.0-rc1``)排在最終 release 之前。Git 範圍與完整 CVSS 向量評分不在範圍內。 diff --git a/docs/source/Zh/doc/new_features/v59_features_doc.rst b/docs/source/Zh/doc/new_features/v59_features_doc.rst index 706e12f65..8fcbc6dc0 100644 --- a/docs/source/Zh/doc/new_features/v59_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v59_features_doc.rst @@ -1,14 +1,14 @@ OpenVEX 漏洞分級 -=============== +================ -``scan_components``(OSV 比對器)會產生漏洞發現項目,但之後每次執行每個已知 CVE 都會一直出現 +``scan_components`` (OSV 比對器)會產生漏洞發現項目,但之後每次執行每個已知 CVE 都會一直出現 —— 沒有辦法記錄「我們看過了,這個不影響我們」並把它捨棄。VEX(Vulnerability Exploitability eXchange)正是這個分級訊號的標準。本功能撰寫 `OpenVEX `_ 0.2.0 陳述並 套用到掃描器的發現項目上。 -``not_affected`` / ``fixed`` 陳述會**抑制**一項發現;``affected`` / ``under_investigation`` -則以評估後的狀態**標註**它。陳述以漏洞 id *或*其任一別名與發現項目配對,並可選擇性地限定到某個 -產品。純標準函式庫(``hashlib`` + ``json`` + ``datetime``);不匯入 ``PySide6``。 +``not_affected`` / ``fixed`` 陳述會 **抑制** 一項發現; ``affected`` / ``under_investigation`` +則以評估後的狀態 **標註** 它。陳述以漏洞 id *或* 其任一別名與發現項目配對,並可選擇性地限定到某個 +產品。純標準函式庫( ``hashlib`` + ``json`` + ``datetime`` );不匯入 ``PySide6`` 。 無頭 API -------- diff --git a/docs/source/Zh/doc/new_features/v5_features_doc.rst b/docs/source/Zh/doc/new_features/v5_features_doc.rst index 965142cc9..4162ab066 100644 --- a/docs/source/Zh/doc/new_features/v5_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v5_features_doc.rst @@ -91,8 +91,8 @@ SQL 查詢僅限單句唯讀的 ``SELECT`` / ``WITH``,以唯讀連線執行,且值一律以 參數綁定(絕不字串拼接)。 -執行器指令:``AC_sql_to_var``(列 / 單列 / 純量存入變數)與 -``AC_assert_db``(對純量查詢以 eq / ne / lt / gt / contains / ... 斷言)。 +執行器指令: ``AC_sql_to_var`` (列 / 單列 / 純量存入變數)與 +``AC_assert_db`` (對純量查詢以 eq / ne / lt / gt / contains / ... 斷言)。 Email(SMTP) diff --git a/docs/source/Zh/doc/new_features/v60_features_doc.rst b/docs/source/Zh/doc/new_features/v60_features_doc.rst index df38dc914..c89cc9443 100644 --- a/docs/source/Zh/doc/new_features/v60_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v60_features_doc.rst @@ -1,5 +1,5 @@ 授權政策閘門 -=========== +============ ``build_sbom`` 會記錄每個元件的授權*名稱*,但從未對它做判斷 —— copyleft 或其他不被允許的授權 可能在無人察覺下隨產品出貨。本功能補上政策閘門:把 SBOM 的授權字串正規化為 SPDX id,以 @@ -29,15 +29,15 @@ write_sarif(license_findings_to_sarif(violations), "licenses.sarif", tool_name="AutoControl-License") -``normalize_spdx`` 把寬鬆的名稱(``"MIT License"`` → ``MIT``、``"Apache 2.0"`` → -``Apache-2.0``)對應到 SPDX id。``evaluate_license`` 回傳 ``allowed`` / ``denied`` / -``unknown``:``deny`` 裡的授權一律不接受;沒給 ``allow`` 時其他授權都接受,空的 ``allow`` 清單則一個都不接受; -缺少或格式錯誤的運算式為 ``unknown``。SPDX **運算式**會被解析 —— ``"MIT OR GPL-3.0-only"`` 是*擇一* -(任一運算元被允許即允許,即使另一個被拒),``"MIT AND GPL-3.0-only"`` 需要每個運算元都被允許,``AND`` -比 ``OR`` 先結合、括號可分組,``X WITH exception`` 以 ``X`` 判定。id 比對不分大小寫,``GPL-2.0+`` 與 -已淘汰的 ``GPL-2.0`` 分別視為 ``GPL-2.0-or-later`` 與 ``GPL-2.0-only``。每個違規為 -``{name, version, license, status}``;``denied`` 對應 SARIF ``error``,``unknown`` 對應 -``warning``。 +``normalize_spdx`` 把寬鬆的名稱( ``"MIT License"`` → ``MIT`` 、 ``"Apache 2.0"`` → +``Apache-2.0`` )對應到 SPDX id。 ``evaluate_license`` 回傳 ``allowed`` / ``denied`` / +``unknown`` : ``deny`` 裡的授權一律不接受;沒給 ``allow`` 時其他授權都接受,空的 ``allow`` 清單則一個都不接受; +缺少或格式錯誤的運算式為 ``unknown`` 。SPDX **運算式** 會被解析 —— ``"MIT OR GPL-3.0-only"`` 是 *擇一* +(任一運算元被允許即允許,即使另一個被拒), ``"MIT AND GPL-3.0-only"`` 需要每個運算元都被允許, ``AND`` +比 ``OR`` 先結合、括號可分組, ``X WITH exception`` 以 ``X`` 判定。id 比對不分大小寫, ``GPL-2.0+`` 與 +已淘汰的 ``GPL-2.0`` 分別視為 ``GPL-2.0-or-later`` 與 ``GPL-2.0-only`` 。每個違規為 +``{name, version, license, status}`` ; ``denied`` 對應 SARIF ``error`` , ``unknown`` 對應 +``warning`` 。 執行器命令 ---------- diff --git a/docs/source/Zh/doc/new_features/v61_features_doc.rst b/docs/source/Zh/doc/new_features/v61_features_doc.rst index a9d1ee3d4..bd6975b2a 100644 --- a/docs/source/Zh/doc/new_features/v61_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v61_features_doc.rst @@ -1,8 +1,8 @@ JSON Web Token(JWT) =================== -RPA 流程經常需要為其驅動的 API 簽發或驗證 bearer token,但框架過去只有 HMAC *檔案*簽章 -(``action_signing``)以及綁定 ACME 的 RS256 JWS(``acme_v2``)—— 兩者都不會產生或驗證精簡的 +RPA 流程經常需要為其驅動的 API 簽發或驗證 bearer token,但框架過去只有 HMAC *檔案* 簽章 +( ``action_signing`` )以及綁定 ACME 的 RS256 JWS( ``acme_v2`` )—— 兩者都不會產生或驗證精簡的 bearer JWT。本功能補上一個聚焦、純標準函式庫的 JWT 編解碼器(HMAC 家族)並含完整的宣告驗證, 設計上可直接餵入 ``http_request`` 的 bearer 驗證。 @@ -16,8 +16,8 @@ bearer JWT。本功能補上一個聚焦、純標準函式庫的 JWT 編解碼 * **拒絕 ``alg: "none"``** 以及任何呼叫端未明確列入允許清單的演算法,藉此擊敗經典的演算法 混淆 / 降級攻擊; -* 以 ``hmac.compare_digest``(常數時間)比較簽章; -* RSA/EC 演算法(RS256/ES256)刻意**不在範圍內** —— 它們需要第三方加密函式庫。 +* 以 ``hmac.compare_digest`` (常數時間)比較簽章; +* RSA/EC 演算法(RS256/ES256)刻意 **不在範圍內** —— 它們需要第三方加密函式庫。 無頭 API -------- diff --git a/docs/source/Zh/doc/new_features/v62_features_doc.rst b/docs/source/Zh/doc/new_features/v62_features_doc.rst index 497962a76..48fd9dd7a 100644 --- a/docs/source/Zh/doc/new_features/v62_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v62_features_doc.rst @@ -1,5 +1,5 @@ 用戶端速率限制 -============= +============== 框架過去有 ``RetryPolicy`` / ``CircuitBreaker``(用於從失敗中*復原*)以及 FIFO 的 ``work_queue``,卻沒有任何東西能塑形呼叫的*速率* —— 因此猛打外部 API 的流程沒有辦法守在配額 diff --git a/docs/source/Zh/doc/new_features/v67_features_doc.rst b/docs/source/Zh/doc/new_features/v67_features_doc.rst index f2a57f65a..02aeab035 100644 --- a/docs/source/Zh/doc/new_features/v67_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v67_features_doc.rst @@ -1,9 +1,9 @@ 文字 Diff、套用與三方合併 -======================== +========================= -``difflib`` 會*產生* unified diff,但標準函式庫無法*套用*它,而且各處都沒有三方合併 —— 因此更新 +``difflib`` 會 *產生* unified diff,但標準函式庫無法 *套用* 它,而且各處都沒有三方合併 —— 因此更新 ``.received`` 產物、重播錄製的文字編輯,或合併對同一基底檔案的兩份編輯,都缺少無頭原語。本功能補上 -缺少的部分,與 ``utils/json_patch``(結構化 JSON)互補;這裡處理以行為單位的文字。 +缺少的部分,與 ``utils/json_patch`` (結構化 JSON)互補;這裡處理以行為單位的文字。 純標準函式庫(``difflib`` + ``re``);不匯入 ``PySide6``。 diff --git a/docs/source/Zh/doc/new_features/v69_features_doc.rst b/docs/source/Zh/doc/new_features/v69_features_doc.rst index 66a42a658..5d996ebd4 100644 --- a/docs/source/Zh/doc/new_features/v69_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v69_features_doc.rst @@ -27,11 +27,11 @@ SLSA 建置來源證明(Provenance) if not mismatches: print("產物摘要已驗證") -``subject_for`` 把檔案雜湊成 in-toto subject(``subject_for_bytes`` 對記憶體資料做同樣的事); -``build_provenance`` 把 subjects 包進 in-toto v1 / SLSA v1 信封(``buildDefinition`` + -``runDetails``);``verify_provenance`` 重新雜湊每個具名檔案並回傳任何摘要不符;沒有提供檔案的 subject 也會列出(``actual`` 為 -``None``),``build_provenance`` 也會拒絕兩個同名的 subject。它與 -``action_signing``(簽署動作 JSON)及 ``sbom``(盤點相依套件)互補 —— 可一併證明 SBOM 與已簽署的 +``subject_for`` 把檔案雜湊成 in-toto subject( ``subject_for_bytes`` 對記憶體資料做同樣的事); +``build_provenance`` 把 subjects 包進 in-toto v1 / SLSA v1 信封( ``buildDefinition`` + +``runDetails`` ); ``verify_provenance`` 重新雜湊每個具名檔案並回傳任何摘要不符;沒有提供檔案的 subject 也會列出( ``actual`` 為 +``None`` ), ``build_provenance`` 也會拒絕兩個同名的 subject。它與 +``action_signing`` (簽署動作 JSON)及 ``sbom`` (盤點相依套件)互補 —— 可一併證明 SBOM 與已簽署的 產物。 執行器命令 diff --git a/docs/source/Zh/doc/new_features/v6_features_doc.rst b/docs/source/Zh/doc/new_features/v6_features_doc.rst index 34a577d08..384f27a0d 100644 --- a/docs/source/Zh/doc/new_features/v6_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v6_features_doc.rst @@ -3,8 +3,8 @@ ============================================ 兩個早已存在、卻從未接上其餘各層的 headless 核心,現在成為一級功能: -**黃金影像視覺回歸**與**宣告式有限狀態機**執行器。兩者都提供 facade -re-export、``AC_*`` 執行器指令、MCP 工具與 Script Builder 項目,並有 +**黃金影像視覺回歸** 與 **宣告式有限狀態機** 執行器。兩者都提供 facade +re-export、 ``AC_*`` 執行器指令、MCP 工具與 Script Builder 項目,並有 headless 測試(PIL 影像 / spec 以注入方式提供,完全不需真實螢幕)。 .. contents:: @@ -33,9 +33,9 @@ headless 測試(PIL 影像 / spec 以注入方式提供,完全不需真實螢幕 執行器指令: -* ``AC_take_golden`` — 擷取並存基準圖(可選 ``region``)。 +* ``AC_take_golden`` — 擷取並存基準圖(可選 ``region`` )。 * ``AC_assert_visual`` — 把畫面與黃金影像比對,不符即拋例外(可存 - ``diff_path``)。**首跑**(基準不存在)時會擷取基準並通過,除非 + ``diff_path`` )。 **首跑** (基準不存在)時會擷取基準並通過,除非 ``create_if_missing`` 設為 false。 diff --git a/docs/source/Zh/doc/new_features/v70_features_doc.rst b/docs/source/Zh/doc/new_features/v70_features_doc.rst index ef071c221..7c0d2b164 100644 --- a/docs/source/Zh/doc/new_features/v70_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v70_features_doc.rst @@ -1,9 +1,9 @@ JSON 合約與快照比對 =================== -``json_schema`` 以撰寫的 schema 驗證一個值,``jsonpath`` 擷取值,但沒有任何東西能以寬鬆規則 -(僅型別、部分、忽略易變路徑)比對兩份 JSON *內容*,或逐路徑對它們取差異以做合約 / 快照測試。 -本功能補上這個層;它與 ``json_schema``(形狀)及 ``json_patch``(結構化編輯)互補。 +``json_schema`` 以撰寫的 schema 驗證一個值, ``jsonpath`` 擷取值,但沒有任何東西能以寬鬆規則 +(僅型別、部分、忽略易變路徑)比對兩份 JSON *內容* ,或逐路徑對它們取差異以做合約 / 快照測試。 +本功能補上這個層;它與 ``json_schema`` (形狀)及 ``json_patch`` (結構化編輯)互補。 純標準函式庫(``json``);具決定性;不匯入 ``PySide6``。 diff --git a/docs/source/Zh/doc/new_features/v71_features_doc.rst b/docs/source/Zh/doc/new_features/v71_features_doc.rst index 71bd265f6..d8fe81b74 100644 --- a/docs/source/Zh/doc/new_features/v71_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v71_features_doc.rst @@ -1,9 +1,9 @@ 混沌實驗(Chaos Experiments) ============================ -``resilience`` 從失敗中*復原*(retry、circuit breaker);這是相反的一面 —— 它*製造*受控的失敗, -並檢查穩態假設是否仍成立。仿照 Chaos Toolkit 生命週期:**之前**驗證穩態、執行**方法**(故障活動)、 -**之後**再驗證穩態,然後永遠執行**回滾**(LIFO)。它回傳一份 journal。 +``resilience`` 從失敗中 *復原* (retry、circuit breaker);這是相反的一面 —— 它 *製造* 受控的失敗, +並檢查穩態假設是否仍成立。仿照 Chaos Toolkit 生命週期: **之前** 驗證穩態、執行 **方法** (故障活動)、 +**之後** 再驗證穩態,然後永遠執行 **回滾** (LIFO)。它回傳一份 journal。 探針、故障與回滾皆為呼叫端提供的 callable,且時鐘 / RNG / sleep 可注入,因此實驗在測試中以假物件 具決定性地執行 —— 沒有真正的失敗、沒有真正的睡眠。純標準函式庫(``random`` + ``time``);不匯入 diff --git a/docs/source/Zh/doc/new_features/v72_features_doc.rst b/docs/source/Zh/doc/new_features/v72_features_doc.rst index 96cec95ee..1704efab2 100644 --- a/docs/source/Zh/doc/new_features/v72_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v72_features_doc.rst @@ -23,11 +23,11 @@ Google SRE workbook 的**多視窗多燃燒率**警示。 for alert in burn_alerts(records, target=0.99): page_oncall(alert) # severity、threshold、long/short 燃燒率 -``evaluate_slo`` 計算 SLI(good / total)、錯誤預算(``(1 - target) * total`` 個事件)與燃燒率 -(``bad_rate / (1 - target)`` —— 1.0 代表剛好按進度消耗預算,> 1 代表太快)。``burn_rate`` 是某 -視窗的純數字。``burn_alerts`` 評估 :func:`default_burn_rules` 的標準 Google SRE 分層 —— 1h(與 +``evaluate_slo`` 計算 SLI(good / total)、錯誤預算( ``(1 - target) * total`` 個事件)與燃燒率 +( ``bad_rate / (1 - target)`` —— 1.0 代表剛好按進度消耗預算,> 1 代表太快)。 ``burn_rate`` 是某 +視窗的純數字。 ``burn_alerts`` 評估 :func:`default_burn_rules` 的標準 Google SRE 分層 —— 1h(與 5m)達 14.4× 呼叫、6h(與 30m)達 6× 呼叫、3d(與 6h)達 1× 開票 —— 且只有當某層的長視窗與短視窗 -**雙雙**超過門檻時才觸發,以取得快速重置與少量誤報。可傳入自訂的 ``rules``(``BurnRule`` 清單)。 +**雙雙** 超過門檻時才觸發,以取得快速重置與少量誤報。可傳入自訂的 ``rules`` ( ``BurnRule`` 清單)。 執行器命令 ---------- diff --git a/docs/source/Zh/doc/new_features/v73_features_doc.rst b/docs/source/Zh/doc/new_features/v73_features_doc.rst index c5732d04d..18345fb4d 100644 --- a/docs/source/Zh/doc/new_features/v73_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v73_features_doc.rst @@ -1,5 +1,5 @@ 串流延遲百分位 -============= +============== ``stats.percentile`` 精確,但需要把整份已排序的樣本清單放在記憶體中;對長時間執行或分片的 load / soak 測試,你會想要一個每筆 O(1)、記憶體有界、且**可合併**的結構。本功能補上 HdrHistogram diff --git a/docs/source/Zh/doc/new_features/v74_features_doc.rst b/docs/source/Zh/doc/new_features/v74_features_doc.rst index c45b094a0..2dfd6dbd4 100644 --- a/docs/source/Zh/doc/new_features/v74_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v74_features_doc.rst @@ -1,5 +1,5 @@ 隔艙與速率限制標頭 -================= +================== ``resilience`` 從失敗中復原,``rate_limit`` 為呼叫調速,但沒有任何東西能限制對單一資源*同時*進行 的呼叫數(因此一個緩慢的相依會耗盡所有 worker),而 HTTP 用戶端會讀取 ``Retry-After`` / diff --git a/docs/source/Zh/doc/new_features/v75_features_doc.rst b/docs/source/Zh/doc/new_features/v75_features_doc.rst index c750d279d..d95e1722f 100644 --- a/docs/source/Zh/doc/new_features/v75_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v75_features_doc.rst @@ -1,5 +1,5 @@ HTTP 錄製與重播卡帶 -================== +=================== HTTP 用戶端把 ``urllib`` 傳輸寫死,因此一個驅動真實 API 的流程,若沒有可連線的線上伺服器便無法在 CI 重跑。用戶端現在開放 ``build_call`` / ``urllib_transport`` 接縫,本層在其上加入 VCR 風格的 diff --git a/docs/source/Zh/doc/new_features/v76_features_doc.rst b/docs/source/Zh/doc/new_features/v76_features_doc.rst index 9c082f2c9..81524c585 100644 --- a/docs/source/Zh/doc/new_features/v76_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v76_features_doc.rst @@ -1,5 +1,5 @@ W3C Trace Context 傳播 -===================== +====================== ``observability`` 追蹤器與 ``agent_trace`` 的 span 都不帶任何 ID,因此一次 HTTP 呼叫一端的 span 無法與它在另一端觸發的工作關聯起來。本功能加入 W3C Trace Context 標準 —— 產生、解析並傳播 diff --git a/docs/source/Zh/doc/new_features/v77_features_doc.rst b/docs/source/Zh/doc/new_features/v77_features_doc.rst index c8dac6c35..fcfd59a43 100644 --- a/docs/source/Zh/doc/new_features/v77_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v77_features_doc.rst @@ -1,5 +1,5 @@ 資料剖析與結構推斷 -================ +================== ``data_quality.validate_rows`` *消費* 一份手寫結構,``stats.describe`` 只彙總單一數值清單 —— 沒有任何 東西能掃描整個資料列集合,回報每欄的空值比例、基數、推斷型別、值域與最常見值,也無法提出一份起始結構。 diff --git a/docs/source/Zh/doc/new_features/v78_features_doc.rst b/docs/source/Zh/doc/new_features/v78_features_doc.rst index 04ed00dbf..e6ee27e16 100644 --- a/docs/source/Zh/doc/new_features/v78_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v78_features_doc.rst @@ -1,5 +1,5 @@ RFC 9457 Problem Details 解析 -============================ +============================= ``http_request`` 回傳的非 2xx 內文未經解析,因此流程 —— 或 ``assert_http`` —— 無法以結構化方式讀取 標準化的 API 錯誤。本功能解析 RFC 9457 ``application/problem+json`` 文件:已註冊的 ``type`` / diff --git a/docs/source/Zh/doc/new_features/v80_features_doc.rst b/docs/source/Zh/doc/new_features/v80_features_doc.rst index 88d8ccf1d..5869f3cf2 100644 --- a/docs/source/Zh/doc/new_features/v80_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v80_features_doc.rst @@ -1,5 +1,5 @@ Server-Sent Events(SSE)用戶端解析器 -================================== +=================================== MCP 的 HTTP 傳輸會*發出* Server-Sent Events,但沒有任何東西消費它:一個串流 ``text/event-stream`` 的 LLM、agent 或 chatops 端點,會讓 ``http_request`` 拿到未經解析的原始 blob。本功能實作 WHATWG diff --git a/docs/source/Zh/doc/new_features/v81_features_doc.rst b/docs/source/Zh/doc/new_features/v81_features_doc.rst index e03ed8239..b6d7f7b7a 100644 --- a/docs/source/Zh/doc/new_features/v81_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v81_features_doc.rst @@ -1,5 +1,5 @@ 分層設定解析器 -============ +============== ``json_patch.merge_patch`` 只合併兩份文件,``config_sync`` 以 last-write-wins 時間戳解析(時間戳相同時刪除優先,其餘每個客戶端都選同一筆,並記為衝突), ``AssetStore`` 則是每環境的扁平結構。它們都無法組成一個有序的 ``defaults < file < env < CLI`` 優先序 diff --git a/docs/source/Zh/doc/new_features/v82_features_doc.rst b/docs/source/Zh/doc/new_features/v82_features_doc.rst index 4d86b55da..01a090f81 100644 --- a/docs/source/Zh/doc/new_features/v82_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v82_features_doc.rst @@ -1,7 +1,7 @@ 分布漂移偵測 -========== +============ -``stats`` 具備針對 A/B *實驗*結果(比例與平均)的雙樣本檢定,但沒有 Population Stability Index,也沒有 +``stats`` 具備針對 A/B *實驗* 結果(比例與平均)的雙樣本檢定,但沒有 Population Stability Index,也沒有 Kolmogorov-Smirnov 雙樣本檢定來做經典的「今天的資料形狀是否與基準一致」檢查。本功能加入 PSI、KS,以及 與 ``data_profile`` 搭配的類別漂移摘要。 diff --git a/docs/source/Zh/doc/new_features/v83_features_doc.rst b/docs/source/Zh/doc/new_features/v83_features_doc.rst index 3f57eae55..0f57ec18c 100644 --- a/docs/source/Zh/doc/new_features/v83_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v83_features_doc.rst @@ -1,5 +1,5 @@ 資料集差異(資料列變更報告) -======================== +========================== 框架能比對*畫面/快照*(``screen_state.diff_snapshots``、``diff_screenshots``),但沒有任何東西能依 鍵比對兩個**表格式**資料列集合 —— 也就是經典的「今天的萃取相較昨天變了什麼」報告。本功能為兩側建立 diff --git a/docs/source/Zh/doc/new_features/v84_features_doc.rst b/docs/source/Zh/doc/new_features/v84_features_doc.rst index 2baf32bf7..b0920e86d 100644 --- a/docs/source/Zh/doc/new_features/v84_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v84_features_doc.rst @@ -1,5 +1,5 @@ W3C Baggage 傳播 -=============== +================ ``trace_context`` 能跨 HTTP 邊界攜帶 trace 與 span 身分,但沒有辦法在旁邊一併傳播橫切的鍵值脈絡 (``run_id`` / ``tenant`` / ``experiment``)。本功能實作 W3C Baggage 標頭 —— 一個 percent-encoded 的 diff --git a/docs/source/Zh/doc/new_features/v85_features_doc.rst b/docs/source/Zh/doc/new_features/v85_features_doc.rst index 84588f972..dd5e85a6e 100644 --- a/docs/source/Zh/doc/new_features/v85_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v85_features_doc.rst @@ -1,5 +1,5 @@ URI-Scheme 值參照 -================ +================= ``script_vars.interpolate`` 把單一間接寫死(``${secrets.NAME}`` → vault),而 ``AssetStore`` 的憑證 參照僅限 vault 名稱。沒有一個通用、可插拔的讀取時間接機制 —— 也就是現代設定模式:儲存一個*指標* diff --git a/docs/source/Zh/doc/new_features/v86_features_doc.rst b/docs/source/Zh/doc/new_features/v86_features_doc.rst index c65ffd08f..235ccd6c8 100644 --- a/docs/source/Zh/doc/new_features/v86_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v86_features_doc.rst @@ -1,5 +1,5 @@ 參照完整性檢查 -============ +============== ``data_quality.validate_rows`` 嚴格限於單列、單表 —— 它的 ``unique`` 規則只在單一批次內去重。沒有跨兩個 已載入資料表的父子外鍵檢查、沒有複合鍵唯一性,也沒有獨立的 accepted-values / row-count 斷言。本功能在 diff --git a/docs/source/Zh/doc/new_features/v87_features_doc.rst b/docs/source/Zh/doc/new_features/v87_features_doc.rst index f27a81e15..8331d52a2 100644 --- a/docs/source/Zh/doc/new_features/v87_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v87_features_doc.rst @@ -1,5 +1,5 @@ RFC 8288 Link 標頭與分頁 -======================= +======================== 分頁的 REST API 會回傳 ``Link: <...>; rel="next"`` 標頭,但沒有任何東西解析它,因此多頁抓取需要手動黏合。 本功能解析該標頭(處理含引號的參數值與多個連結)、依關係索引連結,並透過注入的傳輸走訪 ``rel="next"``。 diff --git a/docs/source/Zh/doc/new_features/v88_features_doc.rst b/docs/source/Zh/doc/new_features/v88_features_doc.rst index cd315452b..35015e454 100644 --- a/docs/source/Zh/doc/new_features/v88_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v88_features_doc.rst @@ -1,5 +1,5 @@ 設定與日誌的機密遮蔽 -================== +==================== ``utils/redaction`` 只會模糊 PIL 截圖,``secrets_scan`` 只會*偵測*並回報發現 —— 兩者都不會回傳一份對 日誌、報告或 ``config_bundle`` 匯出而言安全的、已遮蔽的設定 dict 或字串副本。本功能重用 ``secrets_scan`` diff --git a/docs/source/Zh/doc/new_features/v89_features_doc.rst b/docs/source/Zh/doc/new_features/v89_features_doc.rst index f87489835..a547729d0 100644 --- a/docs/source/Zh/doc/new_features/v89_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v89_features_doc.rst @@ -1,5 +1,5 @@ multipart/form-data 建立與解析 -============================= +============================== ``http_request`` 只送出 JSON 或原始內文 —— 沒有檔案上傳,而曾經能解析 multipart 的標準函式庫 ``cgi`` 模組已在 Python 3.13 移除。本功能以決定性的 boundary 從文字欄位與檔案組裝 ``multipart/form-data`` diff --git a/docs/source/Zh/doc/new_features/v90_features_doc.rst b/docs/source/Zh/doc/new_features/v90_features_doc.rst index 50dbdaefe..57596d5f4 100644 --- a/docs/source/Zh/doc/new_features/v90_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v90_features_doc.rst @@ -1,5 +1,5 @@ HTTP 內容協商與解壓縮 -=================== +===================== ``urllib`` / ``http_request`` 從不設定 ``Accept-Encoding``,也從不解碼 ``Content-Encoding`` 回應,因此 會壓縮的伺服器回傳的內文是原始的;也沒有 quality-value 解析器。本功能加入 ``Accept`` / ``Accept-Encoding`` diff --git a/docs/source/Zh/doc/new_features/v92_features_doc.rst b/docs/source/Zh/doc/new_features/v92_features_doc.rst index ca7b1d80a..029c264e7 100644 --- a/docs/source/Zh/doc/new_features/v92_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v92_features_doc.rst @@ -1,5 +1,5 @@ 條件式 HTTP 請求與快取驗證子 -======================== +============================ ``http_request`` 從不送出 ``If-None-Match`` / ``If-Modified-Since`` 也不讀取 ``Cache-Control``,因此每次 輪詢都會重新下載未變更的資源。本功能從回應擷取快取驗證子、解析 ``Cache-Control``、判定新鮮度,並為下一個 diff --git a/docs/source/Zh/doc/new_features/v93_features_doc.rst b/docs/source/Zh/doc/new_features/v93_features_doc.rst index 762b4875d..4e58f57af 100644 --- a/docs/source/Zh/doc/new_features/v93_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v93_features_doc.rst @@ -1,5 +1,5 @@ 標準日誌行與結構化日誌 -==================== +====================== ``logging_instance`` 輸出固定的管線分隔人類字串,沒有 JSON 選項也沒有 trace/span 欄位,但 OTel 的 log-trace 關聯需要每筆記錄都帶 ``trace_id`` / ``span_id``。本功能加入 Stripe 風格的標準日誌行(每次執行 diff --git a/docs/source/Zh/doc/new_features/v94_features_doc.rst b/docs/source/Zh/doc/new_features/v94_features_doc.rst index 620172b51..e24314b10 100644 --- a/docs/source/Zh/doc/new_features/v94_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v94_features_doc.rst @@ -1,5 +1,5 @@ OTLP/JSON Span 匯出 -================== +=================== OTLP/JSON 的 span 要放在 ``resourceSpans`` / ``scopeSpans`` 封套裡。本功能把一串 span 塑形成 OpenTelemetry collector 可透過 file exporter 直接攝取的封套(``agent_trace.to_otel`` 已回傳 OTLP diff --git a/docs/source/Zh/doc/new_features/v95_features_doc.rst b/docs/source/Zh/doc/new_features/v95_features_doc.rst index 2f31ac3c6..b472d209a 100644 --- a/docs/source/Zh/doc/new_features/v95_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v95_features_doc.rst @@ -1,5 +1,5 @@ 具型別的設定結構 -============== +================ ``assets._coerce`` 只轉換單一值,``json_schema`` 只驗證 JSON 結構,但沒有任何東西能把已解析的設定 dict 綁定成具型別的物件,並做必填欄位強制與選項約束。本功能依宣告的欄位驗證一個 mapping,轉換型別並回報可 diff --git a/docs/source/Zh/doc/new_features/v96_features_doc.rst b/docs/source/Zh/doc/new_features/v96_features_doc.rst index 1e94aa545..8f3fb69a8 100644 --- a/docs/source/Zh/doc/new_features/v96_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v96_features_doc.rst @@ -1,5 +1,5 @@ JSON-Schema 相容性檢查 -===================== +====================== 我們能*依*某個 JSON Schema 驗證(``json_schema``)並*產生*結構(``action_lint/schema``),但無法回答 「使用舊結構的消費者,是否仍能讀取以新結構寫入的資料?」—— 也就是依 Confluent/Avro 的 backward / diff --git a/docs/source/Zh/doc/new_features/v97_features_doc.rst b/docs/source/Zh/doc/new_features/v97_features_doc.rst index eb2bfcc7b..8973e8cb7 100644 --- a/docs/source/Zh/doc/new_features/v97_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v97_features_doc.rst @@ -1,5 +1,5 @@ Unicode 文字正規化與 Slug -======================== +========================= ``fuzzy`` 與 ``search_index.tokenize`` 只做小寫化,OCR ``find_text_matches`` 只做 ``.lower()`` + 子字串 比對 —— 因此 ``"Café"``(NFC)、``"Café"``(NFD)與 OCR 的 ``"cafe"`` 會比對不相等。本功能補上它們在比對 diff --git a/docs/source/Zh/doc/new_features/v98_features_doc.rst b/docs/source/Zh/doc/new_features/v98_features_doc.rst index 9cc9db714..9a4219116 100644 --- a/docs/source/Zh/doc/new_features/v98_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v98_features_doc.rst @@ -1,5 +1,5 @@ 時間序列轉換 -========== +============ ``observability`` 的計數器與量規只儲存*當前*值 —— 沒有任何東西能把計數器轉成每秒速率 —— 而 ``cost_telemetry`` 只以固定的「天」分桶。本功能在 ``(timestamp, value)`` 序列上加入 Prometheus 風格的 @@ -24,10 +24,10 @@ ts_downsample([(0, 1), (3, 3), (5, 10)], 5, "avg") # [(0, 2.0), (5, 10.0)] ts_resample([(0, 0), (20, 20)], 10, fill="linear") # [(0,0),(10,10),(20,20)] -``ts_rate`` / ``ts_increase`` 把值下降視為計數器重置(Prometheus 語意);``ts_irate`` 是最後兩個樣本的 -瞬時速率;``ts_delta`` / ``ts_idelta`` 是量規的首尾差與最後兩點差。``ts_downsample`` 把序列滾成 ``bucket_s`` +``ts_rate`` / ``ts_increase`` 把值下降視為計數器重置(Prometheus 語意); ``ts_irate`` 是最後兩個樣本的 +瞬時速率; ``ts_delta`` / ``ts_idelta`` 是量規的首尾差與最後兩點差。 ``ts_downsample`` 把序列滾成 ``bucket_s`` 的 tumbling 桶,以 ``avg`` / ``sum`` / ``min`` / ``max`` / ``first`` / ``last`` / ``count`` 聚合。 -``ts_resample`` 對齊到固定網格,以 ``"last"``(前向填補)、``"linear"``(內插)或 ``None``(留缺)填值。 +``ts_resample`` 對齊到固定網格,以 ``"last"`` (前向填補)、 ``"linear"`` (內插)或 ``None`` (留缺)填值。 執行器命令 ---------- diff --git a/docs/source/Zh/doc/new_features/v99_features_doc.rst b/docs/source/Zh/doc/new_features/v99_features_doc.rst index 0722db8ae..57ef2c0c4 100644 --- a/docs/source/Zh/doc/new_features/v99_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v99_features_doc.rst @@ -1,5 +1,5 @@ 字串距離相似度量 -============== +================ ``fuzzy`` 只提供 difflib 的 gestalt ratio。本功能補上它缺少的編輯距離與 token 集合度量 —— Levenshtein / Damerau-Levenshtein、Jaro 與 Jaro-Winkler(短名稱/標籤的標準),以及字元 n-gram 的 diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst index c30fc96e9..280e40871 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst @@ -364,13 +364,13 @@ macOS entitlement、Windows WinUSB 通常不需要)。README 會逐 OS 八個原始未決問題均已拍板,對應實作見上方各節: -1. **OQ1 — Channel 可靠度**:``maxRetransmits=None``(完全可靠有序)。 -2. **OQ2 — frame 分片**:已實作 ``fragment_payload`` + EOF 重組。 -3. **OQ3 — ``LIST`` 走 channel**:是,ACL 過濾後經 channel 回傳。 -4. **OQ4 — Backpressure 顆粒度**:per-claim(per-endpoint 屬 YAGNI)。 -5. **OQ5 — WinUSB 不可 claim 裝置**:只列出已綁 WinUSB 的裝置, +1. **OQ1 — Channel 可靠度** : ``maxRetransmits=None`` (完全可靠有序)。 +2. **OQ2 — frame 分片** :已實作 ``fragment_payload`` + EOF 重組。 +3. **OQ3 — ``LIST`` 走 channel** :是,ACL 過濾後經 channel 回傳。 +4. **OQ4 — Backpressure 顆粒度** :per-claim(per-endpoint 屬 YAGNI)。 +5. **OQ5 — WinUSB 不可 claim 裝置** :只列出已綁 WinUSB 的裝置, claim 不到回明確錯誤。 -6. **OQ6 — macOS 發行**:原生 IOKit 列舉 + libusb 傳輸;notarisation, +6. **OQ6 — macOS 發行** :原生 IOKit 列舉 + libusb 傳輸;notarisation, 無需特殊 entitlement,文件記載 SIP 界線。 -7. **OQ7 — Linux kernel driver**:open 時 detach、close 時 reattach。 -8. **OQ8 — ACL 完整性**:HMAC-SHA256 sidecar,金鑰可插拔(keychain)。 +7. **OQ7 — Linux kernel driver** :open 時 detach、close 時 reattach。 +8. **OQ8 — ACL 完整性** :HMAC-SHA256 sidecar,金鑰可插拔(keychain)。 diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst index 50fade05a..774e71d48 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -267,21 +267,34 @@ OPEN 後 host 鍵盤停止運作 Linux:HID 裝置被 claim GUI 能做的事都有對應的 executor 指令,所以 JSON action 檔、socket server 與排程器都能在沒有 GUI 的情況下驅動 USB passthrough: -================================ ============================================ -指令 用途 -================================ ============================================ -``AC_usb_passthrough_enable`` 切換 feature flag(``enabled`` 布林) -``AC_usb_passthrough_status`` 回報是否已啟用 -``AC_usb_acl_list`` 列出 ACL 規則 + 預設 + 完整性狀態 -``AC_usb_acl_add`` 新增 per-device 規則 -``AC_usb_acl_remove`` 移除規則 -``AC_usb_acl_set_default`` 設定預設政策(allow/deny) -``AC_usb_acl_export`` / ``_import`` 以 JSON 備份/還原 ACL -``AC_usb_loopback_list`` 經 loopback 列出 ACL 可見裝置 -``AC_usb_loopback_open`` claim 本機裝置並讀描述元 -``AC_usb_remote_list`` 列出遠端主機裝置(live WebRTC) -``AC_usb_remote_open`` claim 遠端裝置並讀描述元 -================================ ============================================ +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - 指令 + - 用途 + * - ``AC_usb_passthrough_enable`` + - 切換 feature flag(``enabled`` 布林) + * - ``AC_usb_passthrough_status`` + - 回報是否已啟用 + * - ``AC_usb_acl_list`` + - 列出 ACL 規則 + 預設 + 完整性狀態 + * - ``AC_usb_acl_add`` + - 新增 per-device 規則 + * - ``AC_usb_acl_remove`` + - 移除規則 + * - ``AC_usb_acl_set_default`` + - 設定預設政策(allow/deny) + * - ``AC_usb_acl_export`` / ``_import`` + - 以 JSON 備份/還原 ACL + * - ``AC_usb_loopback_list`` + - 經 loopback 列出 ACL 可見裝置 + * - ``AC_usb_loopback_open`` + - claim 本機裝置並讀描述元 + * - ``AC_usb_remote_list`` + - 列出遠端主機裝置(live WebRTC) + * - ``AC_usb_remote_open`` + - claim 遠端裝置並讀描述元 JSON action 範例:: diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index eb1f7a49f..2610e1f66 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -21,7 +21,6 @@ AutoControl 所有功能的完整使用指南。 doc/socket_driver/socket_driver_doc doc/mcp_server/mcp_server_doc doc/ai_agents/ai_agents_doc - doc/ai_agents/ai_agents_doc doc/critical_exit/critical_exit_doc doc/cli/cli_doc doc/create_project/create_project_doc diff --git a/docs/source/conf.py b/docs/source/conf.py index 0bd8b982a..d8fb6a139 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -40,7 +40,7 @@ 'titles_only': False, } -html_static_path = ['_static'] +html_static_path = [] # -- Options for language ---------------------------------------------------- diff --git a/docs/source/getting_started/run_in_ci.rst b/docs/source/getting_started/run_in_ci.rst index e3959339c..3689a98e2 100644 --- a/docs/source/getting_started/run_in_ci.rst +++ b/docs/source/getting_started/run_in_ci.rst @@ -1,6 +1,6 @@ -================================ +================================= Running AutoControl in CI / Cloud -================================ +================================= AutoControl is a GUI automation framework, so by default it expects a real display server. The provided Docker images let you run the same diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 11f9df573..b114f3adc 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -310,3 +310,10 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Dependencies**: Removed `qt-material` from both manifests, both requirements files, the mypy override, licence list and generated lock (uv 0.13.1); the otherwise unused Jinja2/MarkupSafe dependencies also disappear. PyBreeze gets its own `qt-material` through JEDITOR's direct dependency, independently of AutoControl's extra. - **Evidence**: 169 related GUI/contract regressions and 47 documentation, complexity, size, manifest and workspace gates passed. Jeffrey_RPA’s own environment passed all 740 `test_gui_facade.py` / `test_gui_control.py` consumer tests against this worktree (five dependency deprecation warnings). Three-target mypy reported zero failing modules; ruff and bandit passed. Wheel and sdist built successfully; wheel metadata requires only PySide6 for the GUI extra and includes both new workspace modules. The 640×420 offscreen Script Builder rendered in dark and light with the original page identity and a working horizontal scrollbar. Pylint was inspected (native PySide6 symbol inference and existing Qt naming/style findings); radon reported no C-or-higher functions in the changed shell modules. - **Open items**: Remaining F1–F4 work and strict Sphinx warnings are recorded in `Progress.md`; this is not completion of the full GUI plan. + +## U-20261018-02 · 2026-10-18 · Repair Sphinx source markup and remove completed progress history · #done #docs + +- **What**: Corrected heading adornments using docutils' display-width rule, separated inline markup from adjacent Chinese text, removed duplicate AI-agent toctree entries and the nonexistent user-static directory setting, and converted five malformed simple tables to list tables. All executable example blocks in the 141 modified RST files are unchanged. +- **Evidence**: `sphinx -b dummy -E -W --keep-going docs/source ...` succeeds. Full HTML builds with the same strict flags and `-D language=en` / `-D language=zh_TW` both exit 0 without warnings. Each output contains all 512 source pages and the theme stylesheet; all five repaired tables have the expected row/column counts and key contents, and no page contains a Sphinx system-message error block. The prior full build reported 207 warning/error findings. Documentation count, line-count and file-budget checks passed (28 tests). +- **Progress cleanup**: Removed the completed August file-ceiling decision narrative and its stale claim that CI did not enforce file sizes. The exemption table remains because `test_file_length_budget.py` consumes it; historical decisions and code changes remain available in the update log and git history. +- **Validation limit**: No browser connections were available for a graphical preview. HTML structure, generated files and stylesheet assets were verified directly; cross-platform visual GUI acceptance remains in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4cbdac39a..cdc6a5011 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-02 | 2026-10-18 | Repair Sphinx source markup and remove completed progress history | #done #docs | [2026-10](2026-10.md) | | U-20261018-01 | 2026-10-18 | GUI pages scroll and workspace preferences persist without qt-material | #done #gui #F2 | [2026-10](2026-10.md) | | U-20261009-14 | 2026-10-09 | The package gate refuses unlisted packages by default | #done #security #breaking | [2026-10](2026-10.md) | | U-20261009-13 | 2026-10-09 | REST routes and MCP tools are authorised by role when a user store is configured | #done #security #rbac #A10 | [2026-10](2026-10.md) | @@ -364,7 +365,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 28 | +| [2026-10.md](2026-10.md) | 2026-10 | 29 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | From 6e8b83d6b9871b4903994ad7d45007f6e8d11992 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 20:34:42 +0800 Subject: [PATCH 003/294] Preserve registration authority across background automation --- CHANGELOG.md | 15 + Progress.md | 4 +- README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 9 + architecture_explore.md | 32 +- docs/API_LIFECYCLE.md | 8 + .../operations_layer/operations_layer_doc.rst | 19 +- .../operations_layer/operations_layer_doc.rst | 14 +- docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- je_auto_control/utils/hotkey/hotkey_daemon.py | 13 +- je_auto_control/utils/observer/observer.py | 21 +- je_auto_control/utils/rbac/authorization.py | 20 +- je_auto_control/utils/rbac/deferred.py | 51 ++++ .../utils/run_history/run_outcome.py | 13 +- je_auto_control/utils/scheduler/scheduler.py | 14 +- .../utils/triggers/email_trigger.py | 13 +- .../utils/triggers/trigger_engine.py | 16 +- .../utils/triggers/webhook_server.py | 22 +- .../utils/watchdog/popup_watchdog.py | 33 ++- test/conftest.py | 13 + .../headless/test_r3_executor_containment.py | 7 +- .../headless/test_rbac_deferred_callbacks.py | 254 ++++++++++++++++ .../headless/test_rbac_deferred_work.py | 278 ++++++++++++++++++ 26 files changed, 816 insertions(+), 70 deletions(-) create mode 100644 je_auto_control/utils/rbac/deferred.py create mode 100644 test/unit_test/headless/test_rbac_deferred_callbacks.py create mode 100644 test/unit_test/headless/test_rbac_deferred_work.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2644d8fb8..937113b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +### Background authorization + +- Scheduled interval/cron jobs, triggers, hotkeys, watchdog rules, webhooks, + e-mail triggers and observer callbacks retain the identity that registered + them. Store-backed users are checked again on dispatch: removal, damaged + stores and loss of input privileges prevent execution; role changes apply + to privileged commands. Ownership metadata stores no RBAC bearer token. +- Work registered outside RBAC explicitly clears any worker identity. Explicit + Python `AuthorizationContext` values without `store_path` remain role + snapshots. Re-enabling or starting a daemon does not change its jobs' owners. +- Observer and watchdog callbacks get fresh variable scopes. A watchdog action + containing failed executor commands no longer counts as a dismissed popup. + Nested callback runs preserve the caller's prior failure count. A webhook + whose owner was revoked returns HTTP 403 with `fired: false`. + ### GUI workspace - Tab contents scroll when the window is smaller than their minimum layout; diff --git a/Progress.md b/Progress.md index 703bb580d..e81dae6c2 100644 --- a/Progress.md +++ b/Progress.md @@ -211,7 +211,6 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart 私鑰是未加密的 PEM(0600,Windows 上沒驗權限位元),沒有通行碼選項;遷移模式開著時 HMAC 簽章會被接受。 強制簽章經 socket/REST/MCP 的端到端沒有跑過,覆蓋的是共用的 `read_executable_action_json` 與指令本身。 - **RBAC**(`JE_AUTOCONTROL_RBAC_USERS`,預設關閉): - - 延後執行的工作不帶角色:operator 註冊的排程、觸發器、熱鍵、watchdog 之後執行時沒有身分,裡面的特權指令不會被擋。 - 使用者只能用 Python 管(`UserStore.add_user/set_role/rotate_token/remove_user`),沒有 `AC_*`、CLI 或 GUI;`Capability.MANAGE_USERS` 因此沒有任何路由或工具對應。 - `gui/rest_api_tab.py` 仍顯示共用 token,RBAC 開啟後那個 token 會被拒絕。 - viewer 拿得到所有標成唯讀的工具,包括 `ac_sql_query`、`ac_load_dotenv`、`ac_get_clipboard`、`ac_jwt_encode`,沒有重新分類。 @@ -225,8 +224,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 -- **變數範圍**:`je_auto_control run --dry-run --var`、observer 回呼(`AC_observe_add` 與 MCP 的 observe bridge)、 - `utils/llm/planner.py` 與狀態機預設 runner 仍用行程層級的範圍。排程/觸發/熱鍵/webhook/e-mail 的隔離是在 `run_counting_failures` 測的,沒有把每個 daemon 真的觸發一次。 +- **變數範圍**:`je_auto_control run --dry-run --var`、`utils/llm/planner.py` 與狀態機預設 runner 仍用行程層級的範圍。 free-threaded 版(執行緒繼承 context)上,一次執行裡另開的執行緒會看到該次執行的範圍,沒測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index b8a8f037b..d08a2c2e6 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,8 @@ All servers bind to `127.0.0.1` unless you opt in explicitly. **Opt-in hardening.** Each of these is off until configured, and a server without them behaves as before. `JE_AUTOCONTROL_RBAC_USERS=` makes the REST API and the MCP HTTP transport resolve the bearer token to a user and authorise each route, tool and privileged `AC_*` command by role (viewer / operator / admin); the shared token is then refused. `JE_AUTOCONTROL_MCP_PATH_ROOTS` (directories separated by the OS path separator) confines every MCP tool argument that is a file path to those directories, `JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` adds the client's `roots/list`, and `JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` limits which `env://` names `ac_resolve_ref` may read. `JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` makes an endpoint verify Ed25519-signed action files without being able to sign them (`create_signing_keypair`; the private key stays on the signing machine). A remote-desktop viewer writes files pushed by a host only below `~/Downloads/AutoControl` (`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`); that one is on by default. +Delayed interval/cron jobs, triggers, hotkeys, watchdog rules, webhooks, e-mail triggers and observer callbacks retain their registrant’s identity. Store-backed users and roles are checked again on dispatch; deleted users, unavailable stores and viewers cannot execute deferred work. Starting a daemon does not transfer its registrations to the starter. Ownership metadata retains no RBAC bearer token; explicit Python `AuthorizationContext` values without `store_path` keep their captured role, and work registered outside RBAC clears worker identity. Observer/watchdog callbacks get fresh variable scopes, and failed watchdog actions do not count as dismissed popups. Nested callbacks retain the caller’s failure count; a webhook with a revoked owner returns HTTP 403 and `fired: false`. + ### How the remote-desktop wire protocol works Worth knowing before you expose a host, and described nowhere else in the diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index f5f624407..718aa389f 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -281,6 +281,8 @@ je_auto_control version **可选的加固设置。** 下列各项在设置之前都是关闭的,没设置的服务器行为与先前相同。`JE_AUTOCONTROL_RBAC_USERS=<用户文件>` 让 REST API 与 MCP HTTP 传输把 bearer token 对应到用户,并按角色(viewer/operator/admin)授权每一个路由、工具与特权 `AC_*` 命令;此时共用 token 会被拒绝。`JE_AUTOCONTROL_MCP_PATH_ROOTS`(以操作系统的路径分隔符分开的目录)把每一个属于文件路径的 MCP 工具参数限制在这些目录里,`JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` 再加上 client 的 `roots/list`,`JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` 限制 `ac_resolve_ref` 可以读哪些 `env://` 名称。`JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` 让端点只能验证 Ed25519 签名的动作文件而不能签名(`create_signing_keypair`;私钥留在签名的机器上)。远程桌面的 viewer 只把 host 推来的文件写在 `~/Downloads/AutoControl` 下面(`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`);这一项默认就是开的。 +间隔/cron 调度、触发器、热键、watchdog 规则、webhook、e-mail 触发器与 observer 回调会保留注册者身份。每次触发重新检查来源用户文件与角色;已删除的用户、无法使用的来源文件与 viewer 不能执行后台工作。启动 daemon 不会改变已有工作的所有者。所有者信息不保存 RBAC bearer token;未带 `store_path` 的显式 Python `AuthorizationContext` 保留角色快照,未在 RBAC 下注册的工作会清除 worker 原有身份。observer/watchdog 回调使用独立变量作用域,watchdog 动作失败不会算成成功消除弹窗。嵌套回调会保留调用端的失败计数;webhook 所有者被撤销权限时响应 HTTP 403 与 `fired: false`。 + ### 远程桌面的线路协议 把主机开放出去之前值得先了解,而且这一段在其他文档里都没有写。默认传输是**裸 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index dbfb4c4cb..68be41304 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -280,6 +280,8 @@ je_auto_control version **選用的強化設定。** 下列各項在設定之前都是關閉的,沒設定的伺服器行為與先前相同。`JE_AUTOCONTROL_RBAC_USERS=<使用者檔>` 讓 REST API 與 MCP HTTP 傳輸把 bearer token 對到使用者,並依角色(viewer/operator/admin)授權每一個路由、工具與特權 `AC_*` 命令;此時共用 token 會被拒絕。`JE_AUTOCONTROL_MCP_PATH_ROOTS`(以作業系統的路徑分隔字元分開的目錄)把每一個屬於檔案路徑的 MCP 工具參數限制在這些目錄裡,`JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` 再加上 client 的 `roots/list`,`JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` 限制 `ac_resolve_ref` 可以讀哪些 `env://` 名稱。`JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` 讓端點只能驗證 Ed25519 簽章的動作檔而不能簽章(`create_signing_keypair`;私鑰留在簽章的機器上)。遠端桌面的 viewer 只把 host 推來的檔案寫在 `~/Downloads/AutoControl` 底下(`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`);這一項預設就是開的。 +間隔/cron 排程、觸發器、熱鍵、watchdog 規則、webhook、e-mail 觸發器與 observer 回呼會保留註冊者身分。每次觸發重新檢查來源使用者檔與角色;已刪除的使用者、無法使用的來源檔與 viewer 不能執行背景工作。啟動 daemon 不會改變既有工作的擁有者。擁有者資訊不保存 RBAC bearer token;未帶 `store_path` 的明確 Python `AuthorizationContext` 保留角色快照,未在 RBAC 下註冊的工作會清除 worker 原有身分。observer/watchdog 回呼使用獨立變數範圍,watchdog 動作失敗不會算成成功排除彈窗。巢狀回呼會保留呼叫端的失敗計數;webhook 擁有者被撤銷權限時回應 HTTP 403 與 `fired: false`。 + ### 遠端桌面的線路協定 把主機開出去之前值得先知道,而且這段在其他文件裡都沒有寫。預設傳輸是**裸 TCP diff --git a/architecture.md b/architecture.md index 312016d2c..6491f4c24 100644 --- a/architecture.md +++ b/architecture.md @@ -85,6 +85,15 @@ AC_web_run / AC_web_run_actions → utils/webrunner_bridge/bridge.py (event_dict["WR_*"] on a WebRunner without execute_one); a failure → WebRunnerBridgeError ``` +Delayed work captures registration authority through `utils/rbac/deferred.py`. +Schedulers, triggers, hotkeys, watchdogs, webhooks, e-mail watchers and observers +bind that owner at dispatch, independently of the worker's ambient context. +`resolve_token` attaches the source store path to `AuthorizationContext`; dispatch +re-reads that store for current roles and revocations, without retaining bearer +tokens. Trusted local registrations bind no RBAC identity. Observer/watchdog +callbacks also use script jobs' fresh variables and per-run failure deltas, +preserving any earlier failures recorded by the caller. + **C. Record → edit → generate code** ``` diff --git a/architecture_explore.md b/architecture_explore.md index ed5924a3a..4d13e9825 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,8 +19,8 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,079 | -| 程式碼總行數 | 161,748 | +| Python 模組總數(含周邊子專案) | 1,080 | +| 程式碼總行數 | 161,878 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -324,20 +324,20 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.3 排程、觸發與背景監看 -> 11 個套件、約 4,302 行。 +> 11 個套件、約 4,366 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `utils/hotkey/` | 852 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | +| `utils/hotkey/` | 857 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | | `utils/idle_keepawake/` | 245 | 偵測使用者閒置時間並在無人值守執行期間阻止系統睡眠 | | `utils/lock_session/` | 166 | 鎖定工作站、等待解鎖並分類鎖定狀態轉換 | -| `utils/observer/` | 234 | 反應式畫面觀察者,在出現/消失/變化時觸發 | +| `utils/observer/` | 245 | 反應式畫面觀察者,在出現/消失/變化時觸發 | | `utils/recurrence/` | 470 | RFC 5545 重複規則解析與發生時間展開 | -| `utils/scheduler/` | 506 | 間隔式與 cron 式的 action JSON 排程器 | +| `utils/scheduler/` | 512 | 間隔式與 cron 式的 action JSON 排程器 | | `utils/session_guard/` | 62 | 驅動輸入前先偵測工作階段是否已鎖定/非互動 | -| `utils/triggers/` | 1,383 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | +| `utils/triggers/` | 1,412 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | | `utils/voice/` | 99 | 語音指令路由:把辨識到的語句對應到 `AC_*` action list | -| `utils/watchdog/` | 195 | 背景彈窗/中斷看門狗,供無人值守自動化 | +| `utils/watchdog/` | 208 | 背景彈窗/中斷看門狗,供無人值守自動化 | | `utils/watcher/` | 90 | 無頭輪詢原語:滑鼠位置、像素顏色、log tail | ### 5.4.4 輸入模擬與動作品質 @@ -558,7 +558,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.12 報表、可觀測性與測試治理 -> 34 個套件、約 7,721 行。 +> 34 個套件、約 7,726 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -583,7 +583,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/profiler/` | 451 | 逐動作效能剖析器 + 資源剖析器 | | `utils/quarantine/` | 204 | 易碎測試隔離區,讓套件執行器跳過已知不穩定案例 | | `utils/run_diff/` | 143 | 兩次執行軌跡的差異(LCS 對齊:新增/移除/狀態翻轉/退化) | -| `utils/run_history/` | 454 | 執行歷史儲存與產出物管理 | +| `utils/run_history/` | 459 | 執行歷史儲存與產出物管理 | | `utils/sarif/` | 187 | 以 SARIF 2.1.0 匯出發現項,供 GitHub/Azure code scanning | | `utils/slo/` | 115 | SLO 評估:SLI、錯誤預算與多視窗燃燒率告警 | | `utils/smoothing/` | 67 | 數列移動平均平滑 | @@ -630,7 +630,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.14 安全、機密與合規 -> 13 個套件、約 3,403 行。 +> 13 個套件、約 3,464 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -639,7 +639,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/governance/` | 242 | 治理:maker-checker 核准閘門與即時憑證租約 | | `utils/license_policy/` | 240 | 以 SBOM 元件評估 SPDX 授權允許/拒絕政策 | | `utils/provenance/` | 126 | SLSA 建置來源證明(in-toto v1) | -| `utils/rbac/` | 670 | 角色型存取控制:使用者、角色與權杖驗證(尚未接到 REST/MCP) | +| `utils/rbac/` | 731 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。 | | `utils/redaction/` | 508 | 截圖遮蔽層:規則偵測 + 政策 + 協調器(上傳 VLM 前先遮) | | `utils/sbom/` | 170 | SBOM(CycloneDX)產生 | | `utils/secret_ref/` | 238 | URI scheme 形式的值參照解析 | @@ -1094,13 +1094,13 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `utils/agent/` | 9 | 2,142 | | `linux_with_x11/` | 19 | 1,281 | | `linux_wayland/` | 17 | 2,921 | -| `utils/triggers/` | 4 | 1,383 | +| `utils/triggers/` | 4 | 1,412 | | `utils/ocr/` | 10 | 1,381 | | `utils/usbip/` | 5 | 1,008 | | `utils/assertion/` | 3 | 887 | | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | -| `utils/hotkey/` | 7 | 852 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 689 | 58,556 | -| **總計** | **1,073** | **161,683** | +| `utils/hotkey/` | 7 | 857 | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,652 | +| **總計** | **1,074** | **161,813** | diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index 11058c613..5e2705ff9 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -15,3 +15,11 @@ available for compatibility but is not expanded with new integrations. The project remains pre-1.0. A 1.0 release requires passing stable capability tests on every claimed platform, documented recovery/diagnostic behavior, and no unresolved critical security advisories. + +`AuthorizationContext(user_id, role)` remains compatible. Its optional +`store_path` is filled by `resolve_token` so deferred registrations can refresh +the user's current permissions without retaining credentials. Explicit Python +contexts without this path remain role snapshots. Background registration +methods keep their signatures; the worker or daemon starter no longer supplies +the authority of registered jobs. Observer/watchdog callback variable scopes +are independent of the process scope (see `CHANGELOG.md`). diff --git a/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst b/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst index 7782d1108..8095392d8 100644 --- a/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst +++ b/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst @@ -209,9 +209,22 @@ the executor refuses the same commands when they come from an action file. The roles are not a sandbox: an operator drives the real keyboard and can launch programs, so whatever the desktop user could do by hand is within -reach. They protect the host's own privileged state. Work an operator defers -to another thread -- a scheduler job, a trigger, a hotkey binding -- runs -later without the caller's role attached. +reach. They protect the host's own privileged state. Interval/cron jobs, +triggers, hotkeys, watchdog rules, webhooks, e-mail triggers and observer +callbacks keep their registrant's identity. Starting or re-enabling the daemon +does not transfer ownership to that caller. Each dispatch refreshes a +store-backed user: removal, a missing/damaged store or a downgrade to viewer +prevents execution, and privileged commands use the current role. + +``resolve_token`` records only the source ``store_path`` in its +``AuthorizationContext``, never the bearer token. Explicit Python contexts +without a store path retain their captured role. Work registered without RBAC +clears ambient worker authority and keeps trusted local behavior. +Observer and watchdog callbacks also get fresh variable scopes; a failed +watchdog executor action is not counted as a dismissed popup. Observer events +continue to describe detected transitions, with callback failures logged. +Nested callbacks preserve the caller's prior failure count. Webhooks whose +owners are revoked return HTTP 403 with ``fired: false``. Every audit row of an RBAC request names the user: the ``viewer_id`` column holds the user id and the detail reads diff --git a/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst b/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst index 7fcd83e1c..b59da8b42 100644 --- a/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst +++ b/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst @@ -192,8 +192,18 @@ CLI:: 同樣的指令若來自動作檔,則由 executor 拒絕。 角色不是沙箱:operator 操作的是真實鍵盤,也能啟動程式,桌面使用者手動做得到的事 -它都做得到。角色保護的是主機自身的特權狀態。operator 交給其他執行緒延後執行的 -工作(排程工作、觸發器、熱鍵綁定)之後執行時不會帶著呼叫者的角色。 +它都做得到。角色保護的是主機自身的特權狀態。間隔/cron 排程、觸發器、熱鍵、watchdog +規則、webhook、e-mail 觸發器與 observer 回呼會保留註冊者身分。啟動或重新啟用 daemon +不會把工作改成該操作者擁有。每次觸發會重新讀取來源使用者檔:刪除使用者、檔案遺失或 +損壞、降為 viewer 都會阻止執行;特權指令依目前角色檢查。 + +``resolve_token`` 只在 ``AuthorizationContext`` 記錄來源 ``store_path``,不保存 bearer token。 +由 Python 明確建立且未帶來源路徑的 context 則維持角色快照。未在 RBAC 下註冊的工作會 +清除 worker 原有身分,保留本機直接使用的行為。observer 與 watchdog 回呼也會取得獨立 +變數範圍;watchdog 的 executor 動作失敗時不會算成成功排除彈窗。observer 事件仍表示 +偵測到的狀態轉換,回呼失敗會另行記錄。 +巢狀回呼不會清除呼叫端原有的失敗計數。webhook 的擁有者被撤銷權限後,回應為 HTTP 403 +與 ``fired: false``。 RBAC 請求的每一筆稽核紀錄都會記下使用者:``viewer_id`` 欄位存使用者 id,detail 為 ``POST /execute -> ok:200 user=alice role=operator``\ (被拒絕時為 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index b114f3adc..ba10c412e 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -317,3 +317,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Evidence**: `sphinx -b dummy -E -W --keep-going docs/source ...` succeeds. Full HTML builds with the same strict flags and `-D language=en` / `-D language=zh_TW` both exit 0 without warnings. Each output contains all 512 source pages and the theme stylesheet; all five repaired tables have the expected row/column counts and key contents, and no page contains a Sphinx system-message error block. The prior full build reported 207 warning/error findings. Documentation count, line-count and file-budget checks passed (28 tests). - **Progress cleanup**: Removed the completed August file-ceiling decision narrative and its stale claim that CI did not enforce file sizes. The exemption table remains because `test_file_length_budget.py` consumes it; historical decisions and code changes remain available in the update log and git history. - **Validation limit**: No browser connections were available for a graphical preview. HTML structure, generated files and stylesheet assets were verified directly; cross-platform visual GUI acceptance remains in `Progress.md`. + +## U-20261018-03 · 2026-10-18 · Bind deferred work to its registrant and isolate callbacks · #done #security #rbac + +- **What**: `rbac/deferred.py` captures the registering user's immutable authorization context. Interval/cron scheduler jobs, triggers, hotkeys, watchdog rules, webhooks, e-mail triggers and observers explicitly bind it during execution instead of borrowing a daemon starter's/worker's role. Shared trigger/rule objects keep per-registration owners. Store-backed identities re-read the original user file for every dispatch; removed users, corrupt/missing stores and viewers cannot run work, and current roles govern privileged commands. Metadata retains no RBAC bearer token; explicit Python contexts without a store path remain trusted role snapshots, and unscoped local registrations clear ambient worker roles. +- **Callbacks and outcomes**: Observer/watchdog callbacks use fresh variable scopes; a failed watchdog action is not a successful dismissal. `run_counting_failures` uses a per-run delta so a nested poll cannot erase its caller's failures. Revoked webhook owners receive HTTP 403 with `fired: false` and an error history entry; revoked e-mail owners cannot connect to IMAP. Preserved the existing direct `PopupWatchdog._apply(WatchdogRule)` call shape. +- **Evidence**: 62 new tests cover registration identity, scope restoration, real daemon dispatch with controlled native/IMAP backends, real loopback HTTP, deletion/downgrade/corrupt stores, shared rule registrations, and nested failure counts. Full `coverage run -m pytest --timeout=120` passed: **11,454 passed, 69 skipped**, coverage **87.57%**, floor passed. The authorization context/deferred helper/run-outcome modules are **100%** covered including branches. Latest three-target mypy: zero failing modules; ruff, bandit, complexity/file-size/documentation gates pass. English and Chinese strict HTML builds and wheel/sdist builds pass. Jeffrey_RPA's own environment: **740 passed**, five dependency deprecation warnings. +- **Test harness**: The full run exposed CLI tests whose custom workspace `--basetemp` lay outside the OS temp root, plus an obsolete timeout mock targeting `subprocess.run`. Tests now opt their own scratch directory into the existing CLI allowlist and mock `run_captured`; production path defaults and assertions remain intact. Pylint was reviewed; the new authorization modules are clean, with existing daemon/style/internal-record findings elsewhere. +- **Open items**: Remaining RBAC administration/client/tool-classification work, CLI/planner/FSM variable-scope follow-ups and native/free-threaded acceptance remain in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index cdc6a5011..9c9572b03 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-03 | 2026-10-18 | Bind deferred work to its registrant and isolate callbacks | #done #security #rbac | [2026-10](2026-10.md) | | U-20261018-02 | 2026-10-18 | Repair Sphinx source markup and remove completed progress history | #done #docs | [2026-10](2026-10.md) | | U-20261018-01 | 2026-10-18 | GUI pages scroll and workspace preferences persist without qt-material | #done #gui #F2 | [2026-10](2026-10.md) | | U-20261009-14 | 2026-10-09 | The package gate refuses unlisted packages by default | #done #security #breaking | [2026-10](2026-10.md) | @@ -365,7 +366,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 29 | +| [2026-10.md](2026-10.md) | 2026-10 | 30 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/hotkey/hotkey_daemon.py b/je_auto_control/utils/hotkey/hotkey_daemon.py index e2211e03e..2d45b02ff 100644 --- a/je_auto_control/utils/hotkey/hotkey_daemon.py +++ b/je_auto_control/utils/hotkey/hotkey_daemon.py @@ -14,12 +14,13 @@ import sys import threading import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Callable, Dict, FrozenSet, List, Optional, Tuple from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) @@ -52,6 +53,8 @@ class HotkeyBinding: script_path: str enabled: bool = True fired: int = 0 + _authorization: DeferredAuthorization = field( + default_factory=DeferredAuthorization, init=False, repr=False, compare=False) def split_combo(combo: str) -> Tuple[FrozenSet[str], str]: @@ -146,7 +149,7 @@ def __init__(self, def bind(self, combo: str, script_path: str, binding_id: Optional[str] = None) -> HotkeyBinding: - """Register a hotkey → script binding. Safe to call before/after start.""" + """Bind a hotkey for the current caller, before or after daemon start.""" split_combo(combo) # A key the platform cannot take fails here, not later on every tick. if sys.platform == "win32": @@ -159,6 +162,7 @@ def bind(self, combo: str, script_path: str, binding_id=bid, combo=combo, script_path=script_path, ) with self._lock: + binding._authorization = DeferredAuthorization.capture() self._bindings[bid] = binding return binding @@ -218,8 +222,9 @@ def _fire_binding(self, binding_id: str) -> None: status = STATUS_OK error_text: Optional[str] = None try: - actions = read_executable_action_json(match.script_path) - run_counting_failures(lambda: self._execute(actions)) + with match._authorization.scope(): + actions = read_executable_action_json(match.script_path) + run_counting_failures(lambda: self._execute(actions)) except Exception as error: # noqa: BLE001 # reason: this runs on the backend's listener thread; any escape ends every hotkey # AutoControlException covers the common cases — a missing/renamed # script (AutoControlJsonActionException) or an action that raises diff --git a/je_auto_control/utils/observer/observer.py b/je_auto_control/utils/observer/observer.py index 7d1a0282a..bdefceefd 100644 --- a/je_auto_control/utils/observer/observer.py +++ b/je_auto_control/utils/observer/observer.py @@ -14,11 +14,13 @@ """ import threading import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Sequence from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.rbac.deferred import DeferredAuthorization +from je_auto_control.utils.run_history.run_outcome import run_counting_failures from je_auto_control.utils.timeouts import clamp_poll_interval EVENT_APPEAR = "appear" @@ -43,6 +45,8 @@ class WatchRule: on_event: Callable[[str, Any], None] events: Sequence[str] = _ALL_EVENTS last: Any = _UNSET + _authorization: DeferredAuthorization = field( + default_factory=DeferredAuthorization.capture, init=False, repr=False, compare=False) def _transition(last: Any, value: Any) -> Optional[str]: @@ -81,7 +85,12 @@ def __init__(self, poll_interval_s: float = 0.5) -> None: def add(self, name: str, predicate: Callable[[], Any], on_event: Callable[[str, Any], None], *, events: Optional[Sequence[str]] = None) -> WatchRule: - """Register a watch; ``events`` defaults to all three transitions.""" + """Watch as the current caller; callbacks get a fresh variable scope. + + ``events`` defaults to all three transitions. Registrations without + RBAC explicitly clear worker identity; store-backed users are refreshed + at dispatch, including before predicates run. + """ rule = WatchRule(name=name, predicate=predicate, on_event=on_event, events=tuple(events) if events else _ALL_EVENTS) with self._lock: @@ -132,8 +141,9 @@ def _evaluate(self, rule: WatchRule) -> Optional[Dict[str, Any]]: # a numpy array raised from bool() there. One rule may not stop the # poll thread -- and with it every other rule -- whatever it raises. try: - value = rule.predicate() - event = _transition(rule.last, value) + with rule._authorization.scope(): + value = rule.predicate() + event = _transition(rule.last, value) except Exception as error: # noqa: BLE001 # reason: logged; the other rules keep running autocontrol_logger.info( "observer %r predicate error: %r", rule.name, error) @@ -153,7 +163,8 @@ def _evaluate(self, rule: WatchRule) -> Optional[Dict[str, Any]]: def _fire(self, rule: WatchRule, event: str, value: Any) -> None: try: - rule.on_event(event, value) + with rule._authorization.scope(): + run_counting_failures(lambda: rule.on_event(event, value)) except Exception as error: # noqa: BLE001 # reason: logged; the other rules keep running autocontrol_logger.info( "observer %r handler error: %r", rule.name, error) diff --git a/je_auto_control/utils/rbac/authorization.py b/je_auto_control/utils/rbac/authorization.py index 1370c53c4..cdbded526 100644 --- a/je_auto_control/utils/rbac/authorization.py +++ b/je_auto_control/utils/rbac/authorization.py @@ -16,7 +16,7 @@ import contextlib import os from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Iterator, Optional @@ -39,10 +39,18 @@ def __init__(self, message: str, capability: str = "") -> None: @dataclass(frozen=True) class AuthorizationContext: - """One authenticated caller: who it is and the role it holds.""" + """One caller and its role; resolved tokens also carry their source store path. + + ``store_path`` lets deferred work refresh the user's current role without + saving credentials. Manually constructed contexts without a path are + explicit trusted Python role snapshots. + """ user_id: str role: str + # A provenance path, never a bearer token. Deferred work re-reads the + # authoritative store instead of retaining a request's role indefinitely. + store_path: Optional[str] = field(default=None, repr=False, compare=False) def allows(self, capability: str) -> bool: """``True`` when this caller's role grants ``capability``.""" @@ -73,7 +81,8 @@ def resolve_token(store: UserStore, token: str) -> Optional[AuthorizationContext record = store.authenticate(token) except UserAuthError: return None - return AuthorizationContext(user_id=record.user_id, role=record.role) + return AuthorizationContext(user_id=record.user_id, role=record.role, + store_path=str(store.path.resolve())) _CURRENT: ContextVar[Optional[AuthorizationContext]] = ContextVar( @@ -89,8 +98,9 @@ def current_authorization() -> Optional[AuthorizationContext]: def authorization_scope(context: Optional[AuthorizationContext]) -> Iterator[None]: """Serve the enclosed work as ``context``; ``None`` clears any outer scope. - The scope belongs to the calling thread. Work handed to another thread -- - a scheduler job, a trigger, a hotkey -- runs outside it. + The scope belongs to the calling context. Delayed work explicitly binds + its captured registrant through ``DeferredAuthorization.scope()``; it + must not depend on a worker thread's ambient context or inheritance. """ token = _CURRENT.set(context) try: diff --git a/je_auto_control/utils/rbac/deferred.py b/je_auto_control/utils/rbac/deferred.py new file mode 100644 index 000000000..ab737fa21 --- /dev/null +++ b/je_auto_control/utils/rbac/deferred.py @@ -0,0 +1,51 @@ +"""Bind delayed work to its registrant and revalidate stored users on dispatch.""" +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator, Optional + +from je_auto_control.utils.rbac.authorization import ( + AuthorizationContext, AuthorizationError, authorization_scope, current_authorization, +) +from je_auto_control.utils.rbac.users import Capability, UserStore + + +@dataclass(frozen=True) +class DeferredAuthorization: + """A registered work item's authority, independent of its worker's context. + + Store-backed identities are refreshed at each dispatch. Explicit Python + contexts without a store path retain their captured role. No context + represents trusted local work and explicitly clears ambient worker roles. + """ + + context: Optional[AuthorizationContext] = None + + @classmethod + def capture(cls) -> "DeferredAuthorization": + """Capture the current caller when work is registered, not when started.""" + return cls(current_authorization()) + + def _resolve(self) -> Optional[AuthorizationContext]: + context = self.context + if context is None or context.store_path is None: + return context + # A fresh store reads deletions, role changes and damaged/missing files + # even when another process manages the users. Tokens are not retained. + record = UserStore(Path(context.store_path)).get(context.user_id) + if record is None: + raise AuthorizationError(f"deferred work owner {context.user_id!r} is no longer authorised") + return AuthorizationContext(record.user_id, record.role, store_path=context.store_path) + + @contextmanager + def scope(self) -> Iterator[None]: + """Run as the current registrant, refusing revoked or read-only users.""" + context = self._resolve() + with authorization_scope(context): + if context is not None and not context.allows(Capability.DRIVE_INPUT): + raise AuthorizationError( + f"deferred work needs {Capability.DRIVE_INPUT!r}; " + f"user {context.user_id!r} has role {context.role!r}", + capability=Capability.DRIVE_INPUT, + ) + yield diff --git a/je_auto_control/utils/run_history/run_outcome.py b/je_auto_control/utils/run_history/run_outcome.py index e0872acd5..3130d7871 100644 --- a/je_auto_control/utils/run_history/run_outcome.py +++ b/je_auto_control/utils/run_history/run_outcome.py @@ -16,15 +16,20 @@ def run_counting_failures(run: Callable[[], Any]) -> Any: - """Call ``run()`` in a fresh variable scope; raise if an action it ran failed.""" + """Run in fresh variables and count new failures without resetting the caller. + + Manual observer/watchdog polls may run inside another action list. Resetting + the thread's counter here erased that caller's previous failures and could + make the CLI report success. A per-run delta keeps both outcomes intact. + """ from je_auto_control.utils.executor.action_executor import ( - recorded_failures, reset_recorded_failures, + recorded_failures, ) from je_auto_control.utils.script_vars.execution import execution_scope - reset_recorded_failures() + before = recorded_failures() with execution_scope(): result = run() - failures = recorded_failures() + failures = recorded_failures() - before if failures: raise AutoControlActionException( f"{failures} action(s) failed; see this run's record for which") diff --git a/je_auto_control/utils/scheduler/scheduler.py b/je_auto_control/utils/scheduler/scheduler.py index c305ab386..f27cc1811 100644 --- a/je_auto_control/utils/scheduler/scheduler.py +++ b/je_auto_control/utils/scheduler/scheduler.py @@ -12,6 +12,7 @@ from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) @@ -49,6 +50,8 @@ class ScheduledJob: runs: int = 0 enabled: bool = True next_run_ts: float = field(default=0.0) + _authorization: DeferredAuthorization = field( + default_factory=DeferredAuthorization, init=False, repr=False, compare=False) @property def is_cron(self) -> bool: @@ -85,7 +88,7 @@ def __init__(self, executor: Optional[Callable[[list], object]] = None, def add_job(self, script_path: str, interval_seconds: float, repeat: bool = True, max_runs: Optional[int] = None, job_id: Optional[str] = None) -> ScheduledJob: - """Register and schedule a new interval job; return the record.""" + """Register an interval job bound to the current caller; return its record.""" max_runs = _check_max_runs(max_runs) jid = job_id or uuid.uuid4().hex[:8] now = time.monotonic() @@ -97,6 +100,7 @@ def add_job(self, script_path: str, interval_seconds: float, next_run_ts=now + interval, ) with self._lock: + job._authorization = DeferredAuthorization.capture() self._jobs[jid] = job autocontrol_logger.info("scheduler add_job %s %s", jid, script_path) return job @@ -104,7 +108,7 @@ def add_job(self, script_path: str, interval_seconds: float, def add_cron_job(self, script_path: str, cron_expression: str, max_runs: Optional[int] = None, job_id: Optional[str] = None) -> ScheduledJob: - """Register a cron-driven job (5-field expression).""" + """Register a cron job (5 fields) bound to the current caller.""" max_runs = _check_max_runs(max_runs) expression = parse_cron(cron_expression) jid = job_id or uuid.uuid4().hex[:8] @@ -116,6 +120,7 @@ def add_cron_job(self, script_path: str, cron_expression: str, next_run_ts=_next_cron_ts(expression, time.time()), ) with self._lock: + job._authorization = DeferredAuthorization.capture() self._jobs[jid] = job autocontrol_logger.info("scheduler add_cron_job %s %r -> %s", jid, cron_expression, _dt.datetime.fromtimestamp(job.next_run_ts).isoformat()) @@ -230,8 +235,9 @@ def _fire(self, job: ScheduledJob, now_mono: float, now_wall: float) -> None: status = STATUS_OK error_text: Optional[str] = None try: - actions = read_executable_action_json(job.script_path) - run_counting_failures(lambda: self._execute(actions)) + with job._authorization.scope(): + actions = read_executable_action_json(job.script_path) + run_counting_failures(lambda: self._execute(actions)) # 一個排程工作失敗必須記錄為 STATUS_ERROR 並繼續輪詢,絕不能拖垮 # 排程執行緒。原本的 tuple 漏掉 AutoControlException——它是幾乎所有 # action 失敗(找不到視窗/圖片、輸入錯誤)的基底,直接繼承 diff --git a/je_auto_control/utils/triggers/email_trigger.py b/je_auto_control/utils/triggers/email_trigger.py index b74957259..085593c46 100644 --- a/je_auto_control/utils/triggers/email_trigger.py +++ b/je_auto_control/utils/triggers/email_trigger.py @@ -27,6 +27,7 @@ from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) @@ -62,6 +63,8 @@ class EmailTrigger: _seen_uids: set = field(default_factory=set, repr=False) _inflight: set = field(default_factory=set, repr=False) _uidvalidity: Optional[str] = field(default=None, repr=False) + _authorization: DeferredAuthorization = field( + default_factory=DeferredAuthorization.capture, init=False, repr=False, compare=False) def _decode_header_value(value: Optional[str]) -> str: @@ -263,7 +266,7 @@ def add(self, search_criteria: str = "UNSEEN", mark_seen: bool = True, poll_seconds: float = _DEFAULT_POLL_SECONDS) -> EmailTrigger: - """Register a new IMAP trigger.""" + """Register an IMAP trigger bound to the current caller's authority.""" if not host or not username or not script_path: raise ValueError( "host, username, and script_path are required", @@ -364,7 +367,8 @@ def _poll_pass(self) -> int: def _poll_one(self, trigger: EmailTrigger) -> int: try: - client = _connect(trigger) + with trigger._authorization.scope(): + client = _connect(trigger) except (OSError, imaplib.IMAP4.error) as error: self._record_connect_error(trigger, error) return 0 @@ -486,8 +490,9 @@ def _execute_with_history(self, trigger: EmailTrigger, status = STATUS_OK error_text: Optional[str] = None try: - actions = read_executable_action_json(trigger.script_path) - run_counting_failures(lambda: self._executor(actions, payload)) + with trigger._authorization.scope(): + actions = read_executable_action_json(trigger.script_path) + run_counting_failures(lambda: self._executor(actions, payload)) # Any failure is recorded as STATUS_ERROR -- not a bogus # STATUS_OK from the finally below -- before re-raising. except Exception as error: # noqa: BLE001 # reason: re-raised diff --git a/je_auto_control/utils/triggers/trigger_engine.py b/je_auto_control/utils/triggers/trigger_engine.py index 24568d6d9..90236d71b 100644 --- a/je_auto_control/utils/triggers/trigger_engine.py +++ b/je_auto_control/utils/triggers/trigger_engine.py @@ -17,6 +17,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.timeouts import clamp_poll_interval from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, @@ -240,6 +241,7 @@ def __init__(self, executor: Optional[Callable[[list], object]] = None, self._execute = executor or execute_action self._tick = clamp_poll_interval(tick_seconds) self._triggers: Dict[str, _TriggerBase] = {} + self._authorizations: Dict[str, DeferredAuthorization] = {} self._lock = threading.Lock() # start() / stop() race without it: two starts made two polling # threads (every trigger fired twice) and a stop() between creating @@ -250,15 +252,17 @@ def __init__(self, executor: Optional[Callable[[list], object]] = None, self._stop = threading.Event() def add(self, trigger: _TriggerBase) -> _TriggerBase: - """Register ``trigger``; assigns an id when missing.""" + """Register ``trigger`` for the current caller; assign an id when missing.""" if not trigger.trigger_id: trigger.trigger_id = uuid.uuid4().hex[:8] with self._lock: + self._authorizations[trigger.trigger_id] = DeferredAuthorization.capture() self._triggers[trigger.trigger_id] = trigger return trigger def remove(self, trigger_id: str) -> bool: with self._lock: + self._authorizations.pop(trigger_id, None) return self._triggers.pop(trigger_id, None) is not None def list_triggers(self) -> List[_TriggerBase]: @@ -358,14 +362,19 @@ def _is_fired_safely(self, trigger: _TriggerBase) -> bool: return False def _fire(self, trigger: _TriggerBase, now: float) -> None: + with self._lock: + if self._triggers.get(trigger.trigger_id) is not trigger: + return + authorization = self._authorizations[trigger.trigger_id] run_id = default_history_store.start_run( SOURCE_TRIGGER, trigger.trigger_id, trigger.script_path, ) status = STATUS_OK error_text: Optional[str] = None try: - actions = read_executable_action_json(trigger.script_path) - run_counting_failures(lambda: self._execute(actions)) + with authorization.scope(): + actions = read_executable_action_json(trigger.script_path) + run_counting_failures(lambda: self._execute(actions)) # 這裡刻意攔截所有例外:一個 trigger 失敗必須記錄成 STATUS_ERROR # 並繼續,而不是拖垮輪詢執行緒。原本的 tuple 漏掉 # AutoControlJsonActionException,所以光是改名 script 檔就會讓 @@ -397,6 +406,7 @@ def _fire(self, trigger: _TriggerBase, now: float) -> None: live._last_fire = now if not live.repeat: self._triggers.pop(trigger.trigger_id, None) + self._authorizations.pop(trigger.trigger_id, None) default_trigger_engine = TriggerEngine() diff --git a/je_auto_control/utils/triggers/webhook_server.py b/je_auto_control/utils/triggers/webhook_server.py index 927353252..386ceacb2 100644 --- a/je_auto_control/utils/triggers/webhook_server.py +++ b/je_auto_control/utils/triggers/webhook_server.py @@ -28,7 +28,7 @@ import json import threading import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable, Dict, List, Optional, Tuple @@ -41,6 +41,8 @@ ) from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.rbac.authorization import AuthorizationError +from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) @@ -82,6 +84,8 @@ class WebhookTrigger: enabled: bool = True fired: int = 0 last_status: int = 0 + _authorization: DeferredAuthorization = field( + default_factory=DeferredAuthorization.capture, init=False, repr=False, compare=False) #: Verbs the request handler answers; anything else gets a 501 before routing. @@ -229,6 +233,10 @@ def _dispatch(self, method: str) -> None: } try: run_id = registry.fire(trigger, payload) + except AuthorizationError: + self._send_json(HTTPStatus.FORBIDDEN, + {"fired": False, "error": "webhook owner is not authorised"}) + return except AutoControlException as error: # The run-history store failed before the script ran; the client # still gets an answer instead of a dropped connection. @@ -293,7 +301,7 @@ def add(self, methods: Optional[List[str]] = None, token: Optional[str] = None, ) -> WebhookTrigger: - """Register a new webhook → script binding.""" + """Bind a webhook to a script and the current caller's authority.""" normalized_path = _normalize_path(path) if not script_path: raise ValueError("script_path is required") @@ -380,13 +388,19 @@ def fire(self, trigger: WebhookTrigger, # response. Catch the whole family so the fire is recorded as an # error and _dispatch still answers the request. try: - actions = read_executable_action_json(trigger.script_path) - run_counting_failures(lambda: self._executor(actions, payload)) + with trigger._authorization.scope(): + actions = read_executable_action_json(trigger.script_path) + run_counting_failures(lambda: self._executor(actions, payload)) except Exception as error: # noqa: BLE001 # reason: any script failure must be recorded and answered status = STATUS_ERROR error_text = repr(error) autocontrol_logger.error("webhook %s failed: %r", trigger.webhook_id, error) + if isinstance(error, AuthorizationError): + with self._lock: + if self._triggers.get(trigger.webhook_id) is trigger: + trigger.last_status = int(HTTPStatus.FORBIDDEN) + raise finally: artifact = (capture_error_snapshot(run_id) if status == STATUS_ERROR else None) diff --git a/je_auto_control/utils/watchdog/popup_watchdog.py b/je_auto_control/utils/watchdog/popup_watchdog.py index ec81a0d57..613664abe 100644 --- a/je_auto_control/utils/watchdog/popup_watchdog.py +++ b/je_auto_control/utils/watchdog/popup_watchdog.py @@ -21,6 +21,8 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.rbac.deferred import DeferredAuthorization +from je_auto_control.utils.run_history.run_outcome import run_counting_failures from je_auto_control.utils.timeouts import clamp_poll_interval # Errors a rule's matcher/action may raise that must not kill the guard loop @@ -44,13 +46,21 @@ class WatchdogRule: action: Callable[[], None] +@dataclass(frozen=True) +class _RegisteredRule: + """One registration of a rule; shared callback objects keep separate owners.""" + + rule: WatchdogRule + authorization: DeferredAuthorization + + class PopupWatchdog: """Poll for registered popups on a background thread and dismiss them.""" def __init__(self, poll_interval_s: float = 1.0) -> None: # Clamped: an infinite interval raised OverflowError in the poll thread. self._poll = clamp_poll_interval(poll_interval_s) - self._rules: List[WatchdogRule] = [] + self._rules: List[_RegisteredRule] = [] self._lock = threading.Lock() # 序列化 start()/stop():兩者原本無互斥,交錯的 stop() 會在 start() # 指派 _thread 與 .start() 之間 join 未啟動的執行緒 → RuntimeError。 @@ -63,9 +73,9 @@ def __init__(self, poll_interval_s: float = 1.0) -> None: self._hits: Deque[Dict[str, Any]] = deque(maxlen=_MAX_HITS) def add_rule(self, rule: WatchdogRule) -> None: - """Register a generic detector/dismisser rule.""" + """Register a detector/dismisser bound to the current caller's authority.""" with self._lock: - self._rules.append(rule) + self._rules.append(_RegisteredRule(rule, DeferredAuthorization.capture())) def add_window_rule(self, title: str, *, action: str = "close", case_sensitive: bool = False, @@ -98,7 +108,7 @@ def hits(self) -> List[Dict[str, Any]]: def rule_names(self) -> List[str]: with self._lock: - return [rule.name for rule in self._rules] + return [registration.rule.name for registration in self._rules] def start(self) -> None: """Start the guard thread (idempotent).""" @@ -126,16 +136,19 @@ def check_once(self) -> int: dismissed = 0 with self._lock: rules = list(self._rules) - for rule in rules: - if self._apply(rule): + for registration in rules: + if self._apply(registration.rule, registration.authorization): dismissed += 1 return dismissed - def _apply(self, rule: WatchdogRule) -> bool: + def _apply(self, rule: WatchdogRule, + authorization: Optional[DeferredAuthorization] = None) -> bool: + bound = authorization if authorization is not None else DeferredAuthorization.capture() try: - if not rule.matcher(): - return False - rule.action() + with bound.scope(): + if not rule.matcher(): + return False + run_counting_failures(rule.action) # Any rule error, as ScreenObserver does: a matcher raising something # off the list (subprocess.TimeoutExpired, sqlite3.Error) killed the # guard thread and every other rule with it. diff --git a/test/conftest.py b/test/conftest.py index 68ae643f8..bd82c1125 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -20,6 +20,8 @@ import tempfile from pathlib import Path +import pytest + _SCRATCH = Path(tempfile.gettempdir()) / "je_auto_control_pytest" _SCRATCH.mkdir(parents=True, exist_ok=True) _HOME = Path(tempfile.mkdtemp(prefix="home-", dir=_SCRATCH)) @@ -28,3 +30,14 @@ os.environ["HOME"] = str(_HOME) os.environ["USERPROFILE"] = str(_HOME) os.environ["JE_AUTOCONTROL_LOG_FILE"] = str(_SCRATCH / "AutoControlGUI.log") + + +@pytest.fixture(autouse=True) +def permit_test_temporary_paths(tmp_path_factory, monkeypatch): + """Allow CLI artifacts in pytest's scratch root, including custom --basetemp. + + A workspace-owned base directory may be outside the OS temp directory. + Use the CLI's documented opt-in instead of changing production defaults; + individual containment tests can still override or clear the setting. + """ + monkeypatch.setenv("AUTOCONTROL_ALLOWED_PATH_ROOTS", str(tmp_path_factory.getbasetemp())) diff --git a/test/unit_test/headless/test_r3_executor_containment.py b/test/unit_test/headless/test_r3_executor_containment.py index 0ae1ef3f9..e5d49233d 100644 --- a/test/unit_test/headless/test_r3_executor_containment.py +++ b/test/unit_test/headless/test_r3_executor_containment.py @@ -60,10 +60,13 @@ def test_empty_loop_body_is_noop(): def test_shell_to_var_timeout_is_contained(monkeypatch): """A shell timeout is converted to a contained framework error.""" + from je_auto_control.utils.shell_process import shell_exec + def _timeout(*_args, **kwargs): - raise subprocess.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout", 1)) # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit # reason: raising the TimeoutExpired exception class, not spawning a subprocess + raise subprocess.TimeoutExpired( # nosemgrep # reason: raises an exception; no process is spawned + cmd="x", timeout=kwargs.get("timeout", 1)) - monkeypatch.setattr(subprocess, "run", _timeout) + monkeypatch.setattr(shell_exec, "run_captured", _timeout) engine = Executor() record = engine.execute_action( [["AC_shell_to_var", {"command": "sleep 9", "timeout": 1}]], diff --git a/test/unit_test/headless/test_rbac_deferred_callbacks.py b/test/unit_test/headless/test_rbac_deferred_callbacks.py new file mode 100644 index 000000000..d23d805ae --- /dev/null +++ b/test/unit_test/headless/test_rbac_deferred_callbacks.py @@ -0,0 +1,254 @@ +"""Webhook, IMAP and observer callbacks preserve their registration authority.""" +import json +import threading +import urllib.error +import urllib.request +from email.message import EmailMessage + +import pytest + +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.rbac.authorization import ( + AuthorizationContext, AuthorizationError, authorization_scope, current_authorization, resolve_token, +) +from je_auto_control.utils.rbac.users import Role, UserStore +from je_auto_control.utils.run_history.history_store import HistoryStore + +KINDS = ("webhook", "email", "observer") +ADMIN = AuthorizationContext("ambient-admin", Role.ADMIN) +OPERATOR = AuthorizationContext("owner", Role.OPERATOR) + + +@pytest.fixture +def callback_work(tmp_path, monkeypatch): + from je_auto_control.utils.triggers import email_trigger, webhook_server + + history = HistoryStore() + for module in (email_trigger, webhook_server): + monkeypatch.setattr(module, "default_history_store", history) + monkeypatch.setattr(module, "capture_error_snapshot", lambda *_args: None) + path = tmp_path / "actions.json" + calls = [] + monkeypatch.setitem(executor.event_dict, "AC_sign_action_file", lambda: calls.append(current_authorization())) + path.write_text(json.dumps([["AC_sign_action_file"]]), encoding="utf-8") + + def create(kind, context): + registered = [] + with authorization_scope(context): + if kind == "webhook": + service = webhook_server.WebhookTriggerServer() + registered.append(service.add("/hook", str(path))) + def fire(): + return service.fire(registered[0], {}) + elif kind == "email": + service = email_trigger.EmailTriggerWatcher() + registered.append(service.add("imap.invalid", "test", "test-password", str(path), mark_seen=False)) + message = EmailMessage() + message["Subject"] = "Test" + message.set_content("Body") + monkeypatch.setattr(email_trigger, "_fetch_message", lambda *_args: message) + def fire(): + return service._fire_for_uid(object(), registered[0], "1") + else: + from je_auto_control.utils.json.json_file import read_executable_action_json + from je_auto_control.utils.observer.observer import ScreenObserver + service = ScreenObserver() + service.add("watch", lambda: True, + lambda *_args: executor.execute_action(read_executable_action_json(str(path)))) + fire = service.poll_once + return service, fire + + yield create, calls, path + history.close() + + +@pytest.mark.parametrize("kind", KINDS) +def test_callbacks_do_not_borrow_the_dispatching_administrators_role(kind, callback_work): + create, calls, _ = callback_work + _, fire = create(kind, OPERATOR) + with authorization_scope(ADMIN): + fire() + assert current_authorization() is ADMIN + assert calls == [] + + +@pytest.mark.parametrize("kind", KINDS) +def test_callbacks_keep_the_registering_administrator_identity(kind, callback_work): + create, calls, _ = callback_work + _, fire = create(kind, ADMIN) + with authorization_scope(OPERATOR): + fire() + assert calls == [ADMIN] + + +@pytest.mark.parametrize("kind", KINDS) +def test_callback_runs_have_fresh_variables_and_clear_unscoped_authority(kind, callback_work, monkeypatch): + from je_auto_control.utils.script_vars.execution import current_scope, execution_scope + create, _, path = callback_work + seen = [] + path.write_text(json.dumps([["AC_slugify", {"text": "x"}]]), encoding="utf-8") + monkeypatch.setitem(executor.event_dict, "AC_slugify", lambda text: seen.append( + (current_authorization(), current_scope()))) + _, fire = create(kind, None) + with authorization_scope(ADMIN), execution_scope() as outer: + fire() + assert len(seen) == 1 + assert seen[0][0] is None + assert seen[0][1] is not None and seen[0][1] is not outer + + +@pytest.mark.parametrize("kind", KINDS) +def test_deleted_callback_owner_cannot_execute(kind, callback_work, tmp_path): + create, calls, _ = callback_work + store = UserStore(tmp_path / "users.json") + token = store.add_user(user_id="owner", display_name="Owner", role=Role.ADMIN) + _, fire = create(kind, resolve_token(store, token)) + UserStore(store.path).remove_user("owner") + with authorization_scope(ADMIN): + if kind == "webhook": + with pytest.raises(AuthorizationError): + fire() + else: + fire() + assert calls == [] + + +@pytest.mark.parametrize("kind", KINDS) +def test_live_callback_dispatch_binds_owner_and_isolates_variables(kind, callback_work, monkeypatch): + from je_auto_control.utils.script_vars.execution import current_scope + create, _, path = callback_work + path.write_text(json.dumps([["AC_slugify", {"text": "x"}]]), encoding="utf-8") + reached = threading.Event() + finished = threading.Event() + seen = [] + + def step(text): + seen.append((current_authorization(), current_scope())) + reached.set() + + monkeypatch.setitem(executor.event_dict, "AC_slugify", step) + service, _ = create(kind, OPERATOR) + if kind == "email": + from je_auto_control.utils.triggers import email_trigger + + class FakeIMAP: + def select(self, *_args, **_kwargs): + return "OK", [] + + def response(self, *_args): + return "OK", [b"1"] + + def uid(self, *_args): + return "OK", [b"1"] + + def logout(self): + finished.set() + + def connect(_trigger): + assert current_authorization() == OPERATOR + return FakeIMAP() + + monkeypatch.setattr(email_trigger, "_connect", connect) + try: + with authorization_scope(ADMIN): + if kind == "webhook": + host, port = service.start("127.0.0.1", 0) + request = urllib.request.Request(f"http://{host}:{port}/hook", data=b"", method="POST") + with urllib.request.urlopen(request, timeout=5) as response: # nosec B310 # reason: local ephemeral test server + assert response.status == 200 + else: + service.start() + assert reached.wait(5), f"{kind} did not dispatch" + if kind == "email": + assert finished.wait(5), "mail polling did not release its client" + finally: + service.stop() + assert len(seen) == 1 + assert seen[0][0] == OPERATOR + assert seen[0][1] is not None + assert current_authorization() is None + + +def test_deleted_email_owner_cannot_open_the_mailbox(callback_work, monkeypatch, tmp_path): + from je_auto_control.utils.triggers import email_trigger + create, _, _ = callback_work + store = UserStore(tmp_path / "users.json") + token = store.add_user(user_id="owner", display_name="Owner", role=Role.ADMIN) + service, _ = create("email", resolve_token(store, token)) + UserStore(store.path).remove_user("owner") + connected = [] + monkeypatch.setattr(email_trigger, "_connect", lambda *_args: connected.append(True)) + assert service.poll_once() == 0 + assert connected == [] + + +def test_http_webhook_refuses_a_deleted_owner_instead_of_acknowledging_a_fire(callback_work, tmp_path): + from je_auto_control.utils.triggers import webhook_server + create, calls, _ = callback_work + store = UserStore(tmp_path / "users.json") + token = store.add_user(user_id="owner", display_name="Owner", role=Role.ADMIN) + service, _ = create("webhook", resolve_token(store, token)) + UserStore(store.path).remove_user("owner") + host, port = service.start("127.0.0.1", 0) + try: + request = urllib.request.Request(f"http://{host}:{port}/hook", data=b"", method="POST") + with pytest.raises(urllib.error.HTTPError) as caught: + urllib.request.urlopen(request, timeout=5) # nosec B310 # reason: local ephemeral test server + with caught.value as response: + assert response.code == 403 + assert json.loads(response.read())["fired"] is False + assert calls == [] + assert service.list_webhooks()[0].last_status == 403 + assert webhook_server.default_history_store.list_runs()[0].status == "error" + finally: + service.stop() + + +@pytest.mark.parametrize("kind", ("observer", "watchdog")) +def test_polling_a_successful_callback_does_not_erase_the_callers_failure_count(kind, monkeypatch): + from je_auto_control.utils.exception.exceptions import AutoControlActionException + from je_auto_control.utils.executor.action_executor import recorded_failures, reset_recorded_failures + + def fail(text): + raise AutoControlActionException("expected failure") + + monkeypatch.setitem(executor.event_dict, "AC_slugify", fail) + reset_recorded_failures() + try: + executor.execute_action([["AC_slugify", {"text": "seed"}]]) + assert recorded_failures() == 1 + monkeypatch.setitem(executor.event_dict, "AC_slugify", lambda text: text) + actions = [["AC_slugify", {"text": "callback"}]] + if kind == "observer": + from je_auto_control.utils.observer.observer import ScreenObserver + service = ScreenObserver() + service.add("ok", lambda: True, lambda *_args: executor.execute_action(actions)) + service.poll_once() + else: + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + service = PopupWatchdog() + service.add_rule(WatchdogRule("ok", lambda: True, lambda: executor.execute_action(actions))) + assert service.check_once() == 1 + assert recorded_failures() == 1 + finally: + reset_recorded_failures() + + +def test_nested_run_counts_only_new_failures_and_preserves_the_parent_count(monkeypatch): + from je_auto_control.utils.exception.exceptions import AutoControlActionException + from je_auto_control.utils.executor.action_executor import recorded_failures, reset_recorded_failures + from je_auto_control.utils.run_history.run_outcome import run_counting_failures + + def fail(text): + raise AutoControlActionException("expected failure") + + monkeypatch.setitem(executor.event_dict, "AC_slugify", fail) + reset_recorded_failures() + try: + actions = [["AC_slugify", {"text": "failure"}]] + executor.execute_action(actions) + with pytest.raises(AutoControlActionException, match="1 action"): + run_counting_failures(lambda: executor.execute_action(actions)) + assert recorded_failures() == 2 + finally: + reset_recorded_failures() diff --git a/test/unit_test/headless/test_rbac_deferred_work.py b/test/unit_test/headless/test_rbac_deferred_work.py new file mode 100644 index 000000000..6c9ce167f --- /dev/null +++ b/test/unit_test/headless/test_rbac_deferred_work.py @@ -0,0 +1,278 @@ +"""Delayed work uses its registrant's authority, including revocation checks.""" +import json +import threading +from dataclasses import dataclass +from typing import Callable + +import pytest + +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.rbac.authorization import ( + AuthorizationContext, authorization_scope, current_authorization, resolve_token, +) +from je_auto_control.utils.rbac.users import Role, UserStore +from je_auto_control.utils.run_history.history_store import HistoryStore, STATUS_ERROR + +KINDS = ("interval", "cron", "trigger", "hotkey", "watchdog") +ADMIN = AuthorizationContext("worker-admin", Role.ADMIN) +OPERATOR = AuthorizationContext("registrant", Role.OPERATOR) + + +@dataclass +class Work: + register: Callable[[], object] + fire: Callable[[], object] + + +@pytest.fixture +def actions(tmp_path, monkeypatch): + path = tmp_path / "actions.json" + path.write_text(json.dumps([["AC_sign_action_file"]]), encoding="utf-8") + calls = [] + monkeypatch.setitem(executor.event_dict, "AC_sign_action_file", lambda: calls.append(current_authorization())) + return path, calls + + +@pytest.fixture +def history(monkeypatch): + from je_auto_control.utils.hotkey import hotkey_daemon + from je_auto_control.utils.scheduler import scheduler + from je_auto_control.utils.triggers import trigger_engine + store = HistoryStore() + for module in (hotkey_daemon, scheduler, trigger_engine): + monkeypatch.setattr(module, "default_history_store", store) + monkeypatch.setattr(module, "capture_error_snapshot", lambda *_args: None) + yield store + store.close() + + +def _work(kind, path): + """Construct detectors outside the registration scope, with no native input.""" + if kind in ("interval", "cron"): + from je_auto_control.utils.scheduler.scheduler import Scheduler + scheduler = Scheduler() + registered = [] + + def register(): + if kind == "interval": + job = scheduler.add_job(str(path), 60, repeat=False) + else: + job = scheduler.add_cron_job(str(path), "* * * * *", max_runs=1) + registered.append(job) + + return Work(register, lambda: scheduler._fire(registered[0], 0, 0)) + if kind == "trigger": + from je_auto_control.utils.triggers.trigger_engine import FilePathTrigger, TriggerEngine + engine = TriggerEngine() + trigger = FilePathTrigger(trigger_id="t", script_path=str(path)) + return Work(lambda: engine.add(trigger), lambda: engine._fire(trigger, 0)) + if kind == "hotkey": + from je_auto_control.utils.hotkey.hotkey_daemon import HotkeyDaemon + daemon = HotkeyDaemon() + return Work(lambda: daemon.bind("ctrl+alt+a", str(path), binding_id="h"), + lambda: daemon._fire_binding("h")) + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + watchdog = PopupWatchdog() + from je_auto_control.utils.json.json_file import read_executable_action_json + rule = WatchdogRule("r", lambda: True, + lambda: executor.execute_action(read_executable_action_json(str(path)))) + return Work(lambda: watchdog.add_rule(rule), watchdog.check_once) + + +def _fire_on_worker(work, worker=ADMIN): + outcomes = [] + errors = [] + + def run(): + try: + with authorization_scope(worker): + outcomes.append(work.fire()) + assert current_authorization() is worker + except BaseException as error: + errors.append(error) + + thread = threading.Thread(target=run) + thread.start() + thread.join(timeout=5) + assert not thread.is_alive(), "deferred work did not finish" + assert not errors, errors + return outcomes + + +@pytest.mark.parametrize("kind", KINDS) +def test_operator_cannot_gain_the_worker_threads_administrative_role(kind, actions, history): + path, calls = actions + work = _work(kind, path) + with authorization_scope(OPERATOR): + work.register() + result = _fire_on_worker(work) + assert calls == [], "the registrant's privileged command ran under another caller's authority" + if kind == "watchdog": + assert result == [0], "a denied command must not be recorded as a dismissed popup" + if kind != "watchdog": + assert history.list_runs()[0].status == STATUS_ERROR + + +@pytest.mark.parametrize("kind", KINDS) +def test_admin_registration_keeps_its_identity_on_an_unprivileged_worker(kind, actions, history): + path, calls = actions + work = _work(kind, path) + owner = AuthorizationContext("owner-admin", Role.ADMIN) + with authorization_scope(owner): + work.register() + _fire_on_worker(work, worker=OPERATOR) + assert calls == [owner] + + +@pytest.mark.parametrize("kind", KINDS) +def test_work_registered_without_rbac_clears_ambient_worker_authority(kind, actions, history): + path, calls = actions + work = _work(kind, path) + with authorization_scope(None): + work.register() + _fire_on_worker(work, worker=OPERATOR) + assert calls == [None] + + +@pytest.mark.parametrize("change", ["remove", "viewer", "corrupt"]) +@pytest.mark.parametrize("kind", KINDS) +def test_revoked_or_downgraded_registrants_cannot_execute(kind, change, actions, history, tmp_path): + path, calls = actions + store = UserStore(tmp_path / "users.json") + token = store.add_user(user_id="owner", display_name="Owner", role=Role.ADMIN) + owner = resolve_token(store, token) + assert owner is not None + work = _work(kind, path) + with authorization_scope(owner): + work.register() + # A separately opened store stands in for the administration process. + other = UserStore(store.path) + if change == "remove": + other.remove_user("owner") + elif change == "viewer": + other.set_role("owner", Role.VIEWER) + else: + store.path.write_text("corrupted", encoding="utf-8") + _fire_on_worker(work) + assert calls == [] + if kind != "watchdog": + assert history.list_runs()[0].status == STATUS_ERROR + + +@pytest.mark.parametrize("kind", KINDS) +def test_downgrade_to_operator_blocks_privileged_commands_but_runs_safe_work( + kind, actions, history, tmp_path, monkeypatch): + path, calls = actions + store = UserStore(tmp_path / "users.json") + token = store.add_user(user_id="owner", display_name="Owner", role=Role.ADMIN) + owner = resolve_token(store, token) + work = _work(kind, path) + with authorization_scope(owner): + work.register() + UserStore(store.path).set_role("owner", Role.OPERATOR) + safe_calls = [] + monkeypatch.setitem(executor.event_dict, "AC_slugify", lambda text: safe_calls.append(current_authorization())) + path.write_text(json.dumps([["AC_slugify", {"text": "safe"}], ["AC_sign_action_file"]]), encoding="utf-8") + _fire_on_worker(work) + assert calls == [] + assert [(call.user_id, call.role) for call in safe_calls] == [("owner", Role.OPERATOR)] + + +@pytest.mark.parametrize("kind", ("trigger", "watchdog")) +def test_shared_rule_objects_do_not_share_registration_authority(kind, actions, history): + path, calls = actions + if kind == "trigger": + from je_auto_control.utils.triggers.trigger_engine import FilePathTrigger, TriggerEngine + first, second = TriggerEngine(), TriggerEngine() + shared = FilePathTrigger(trigger_id="shared", script_path=str(path)) + def register_first(): + return first.add(shared) + def register_second(): + return second.add(shared) + def fire_first(): + return first._fire(shared, 0) + def fire_second(): + return second._fire(shared, 0) + else: + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + first, second = PopupWatchdog(), PopupWatchdog() + shared = WatchdogRule("shared", lambda: True, + lambda: executor.execute_action([["AC_sign_action_file"]])) + def register_first(): + return first.add_rule(shared) + def register_second(): + return second.add_rule(shared) + fire_first, fire_second = first.check_once, second.check_once + with authorization_scope(OPERATOR): + register_first() + with authorization_scope(ADMIN): + register_second() + fire_first() + assert calls == [] + fire_second() + assert calls == [ADMIN] + + +@pytest.mark.parametrize("kind", KINDS) +def test_live_daemon_dispatch_preserves_identity_and_opens_a_fresh_variable_scope( + kind, actions, history, monkeypatch): + from je_auto_control.utils.script_vars.execution import current_scope + + path, _ = actions + path.write_text(json.dumps([["AC_slugify", {"text": "tick"}]]), encoding="utf-8") + reached = threading.Event() + seen = [] + + def step(text): + seen.append((current_authorization(), current_scope())) + reached.set() + + monkeypatch.setitem(executor.event_dict, "AC_slugify", step) + with authorization_scope(OPERATOR): + if kind in ("interval", "cron"): + from je_auto_control.utils.scheduler.scheduler import Scheduler + daemon = Scheduler(tick_seconds=0.1) + if kind == "cron": + job = daemon.add_cron_job(str(path), "* * * * *", max_runs=1) + job.next_run_ts = 0 + else: + daemon.add_job(str(path), 0.1, repeat=False) + elif kind == "trigger": + from je_auto_control.utils.triggers.trigger_engine import _TriggerBase, TriggerEngine + + class ReadyTrigger(_TriggerBase): + def is_fired(self): + return True + + daemon = TriggerEngine(tick_seconds=0.01) + daemon.add(ReadyTrigger("tick", str(path), cooldown_seconds=0)) + elif kind == "hotkey": + from je_auto_control.utils.hotkey import backends + from je_auto_control.utils.hotkey.hotkey_daemon import HotkeyDaemon + + class FakeNativeBackend: + name = "test" + + def run_forever(self, context): + context.fire("tick") + context.stop_event.wait(5) + + monkeypatch.setattr(backends, "get_backend", lambda: FakeNativeBackend()) + daemon = HotkeyDaemon() + daemon.bind("ctrl+alt+a", str(path), binding_id="tick") + else: + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + from je_auto_control.utils.json.json_file import read_executable_action_json + daemon = PopupWatchdog(poll_interval_s=0.01) + daemon.add_rule(WatchdogRule("tick", lambda: not reached.is_set(), + lambda: executor.execute_action(read_executable_action_json(str(path))))) + try: + with authorization_scope(ADMIN): + daemon.start() + assert reached.wait(5), f"{kind} did not dispatch" + finally: + daemon.stop(timeout=5) + assert len(seen) == 1 + assert seen[0][0] == OPERATOR + assert seen[0][1] is not None + assert current_authorization() is None From c8d8c35ee8dd94798885255e3e291546cbd7ba0f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 20:35:44 +0800 Subject: [PATCH 004/294] Record current ARM64 and Intel dependency constraints --- Progress.md | 2 +- docs/updates/2026-10.md | 8 ++++++++ docs/updates/README.md | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Progress.md b/Progress.md index e81dae6c2..a3a612f5c 100644 --- a/Progress.md +++ b/Progress.md @@ -96,7 +96,7 @@ sys_platform != 'win32' or platform_machine != 'ARM64' | `cryptography>=50.0.0` | **沒有** | wheel 只出到 **46.0.3**,46.0.4 起上游就不再發 win_arm64。`>=50.0.0` 是**安全下限**(GHSA-537c-gmf6-5ccf 與 GHSA-g6cj-pr64-35w5),不能為了 arm64 降回去。 | | `pillow==12.3.0` | 有 | `pillow-12.3.0-cp3xx-win_arm64.whl` 一直都在。**曾經被寫成卡點,那是猜的,它從來不是。** | | `mss`/`defusedxml` | 有 | 純 Python。這三個加上 Pillow 就是 arm64 實際裝到的全部。 | -| `PySide6==6.11.1` | 有 | `[gui]` extra 在 arm64 上裝得起來。 | +| `PySide6==6.11.2` | 有 | `[gui]` extra 在 arm64 上裝得起來。 | | `aiortc` | **沒有** | 卡在傳遞相依 `google-crc32c`,與本專案的選擇無關;`av` 自己有 wheel。 | 重驗指令(不需要 arm64 機器,也不需要 runner): diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index ba10c412e..82f72ebdb 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -325,3 +325,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Evidence**: 62 new tests cover registration identity, scope restoration, real daemon dispatch with controlled native/IMAP backends, real loopback HTTP, deletion/downgrade/corrupt stores, shared rule registrations, and nested failure counts. Full `coverage run -m pytest --timeout=120` passed: **11,454 passed, 69 skipped**, coverage **87.57%**, floor passed. The authorization context/deferred helper/run-outcome modules are **100%** covered including branches. Latest three-target mypy: zero failing modules; ruff, bandit, complexity/file-size/documentation gates pass. English and Chinese strict HTML builds and wheel/sdist builds pass. Jeffrey_RPA's own environment: **740 passed**, five dependency deprecation warnings. - **Test harness**: The full run exposed CLI tests whose custom workspace `--basetemp` lay outside the OS temp root, plus an obsolete timeout mock targeting `subprocess.run`. Tests now opt their own scratch directory into the existing CLI allowlist and mock `run_captured`; production path defaults and assertions remain intact. Pylint was reviewed; the new authorization modules are clean, with existing daemon/style/internal-record findings elsewhere. - **Open items**: Remaining RBAC administration/client/tool-classification work, CLI/planner/FSM variable-scope follow-ups and native/free-threaded acceptance remain in `Progress.md`. + +## U-20261018-04 · 2026-10-18 · Recheck platform wheel gaps without lowering security floors · #snapshot #deps #platforms + +- **Probe**: pip 26.0.1 `install --dry-run --only-binary=:all: --platform --python-version 3.14 --target `; individual cryptography/OpenCV probes used `--no-deps` so an unrelated dependency cannot hide which wheel is missing. These are binary-availability probes, not native execution or marker-evaluation tests. +- **Windows ARM64**: `cryptography>=50.0.0` still has no matching wheel (available compatible versions end at 46.0.3); `opencv-python>=4.8,<6` still has none. Both dependency markers and the cryptography security floor remain necessary. `PySide6==6.11.2` resolves with its matching Addons, Essentials and shiboken6 ARM64 wheels; updated that current pin in `Progress.md`. +- **Intel macOS**: both `macosx_10_9_x86_64` and `macosx_13_0_x86_64` still have no cryptography wheel satisfying the security floor (compatible versions end at 48.0.1). An actual Intel source build is still unverified. +- **Local Linux verification**: Docker CLI and the docker-desktop WSL distribution exist, but the Docker Desktop Linux engine pipe is absent. No daemon or production environment was restarted. This does not replace native Wayland/CI peer evidence. +- **Open items**: platform functionality, Intel source-build and native Wayland acceptance remain in `Progress.md`; this snapshot is not completion of those items. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9c9572b03..324fdc28b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-04 | 2026-10-18 | Recheck platform wheel gaps without lowering security floors | #snapshot #deps #platforms | [2026-10](2026-10.md) | | U-20261018-03 | 2026-10-18 | Bind deferred work to its registrant and isolate callbacks | #done #security #rbac | [2026-10](2026-10.md) | | U-20261018-02 | 2026-10-18 | Repair Sphinx source markup and remove completed progress history | #done #docs | [2026-10](2026-10.md) | | U-20261018-01 | 2026-10-18 | GUI pages scroll and workspace preferences persist without qt-material | #done #gui #F2 | [2026-10](2026-10.md) | @@ -366,7 +367,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 30 | +| [2026-10.md](2026-10.md) | 2026-10 | 31 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | From 70a61dc65f633034d36e3eca001a2d40132a70b5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 20:42:02 +0800 Subject: [PATCH 005/294] Report refused MCP window operations as tool errors --- CHANGELOG.md | 6 ++ Progress.md | 2 - README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture_explore.md | 14 ++-- .../Eng/doc/mcp_server/mcp_server_doc.rst | 4 + .../Zh/doc/mcp_server/mcp_server_doc.rst | 3 + docs/updates/2026-10.md | 7 ++ docs/updates/README.md | 3 +- .../mcp_server/tools/_handlers_system.py | 9 ++- test/unit_test/headless/test_mcp_server.py | 3 +- .../headless/test_mcp_window_state_outcome.py | 79 +++++++++++++++++++ 13 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 test/unit_test/headless/test_mcp_window_state_outcome.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 937113b57..0c9edc9aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- MCP `ac_window_minimize`, `ac_window_maximize` and `ac_window_restore` + return a tool error when the backend refuses the state/activation request, + instead of reporting the handle as success. Successful calls still return + the same integer handle; raw Win32 previous-visibility values are normalized + by the existing backend. + ### Background authorization - Scheduled interval/cron jobs, triggers, hotkeys, watchdog rules, webhooks, diff --git a/Progress.md b/Progress.md index a3a612f5c..565c9a255 100644 --- a/Progress.md +++ b/Progress.md @@ -177,8 +177,6 @@ pip install --dry-run --only-binary=:all: --platform win_arm64 --python-version 但鍵表的註解刻意不替 `oem_*` 取好讀的別名,因為它們隨鍵盤配置而變(`oem_2` 只有在美式配置上是 `/`)。 要加的話是 `wrapper/_platform_windows.py` 的 `keyboard_key_aliases` 一行。 -**MCP 的 `_show_command`**(`utils/mcp_server/tools/_handlers_system.py`,`window_minimize` 等)仍不看 `show_window` 的新回傳值。 - --- ## macOS 的修正:CI 驗到一半,其餘要一台真的 Mac diff --git a/README.md b/README.md index d08a2c2e6..aafbf295a 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,8 @@ under a relative name are written below that directory. All servers bind to `127.0.0.1` unless you opt in explicitly. +The MCP window-state tools `ac_window_minimize`, `ac_window_maximize` and `ac_window_restore` return the window handle on accepted requests and `isError: true` on backend refusal (including a stale handle or rejected activation). Raw Win32 `ShowWindow` previous visibility is normalized by the backend. + **Package gate.** `AC_add_package_to_executor` and `AC_add_package_to_callback_executor` import a Python package and register its members as commands, so an action list arriving over any of these surfaces could load `os` or `subprocess`. No package loads unless it has been allowed: a package that is not on the allowlist is refused before it is imported, and that action fails with `AutoControlExecuteActionException`. Allow packages (submodules included) from Python with `executor.allow_packages("name", …)`, for every entry point — both CLIs, the socket / REST / MCP servers and the scheduler — with the `JE_AUTOCONTROL_ALLOWED_PACKAGES` environment variable (comma-separated names, read when the process starts), or for one CLI run with `je_auto_control run script.json --allow-package NAME` (repeatable). `executor.set_allow_arbitrary_packages(True)` opens the gate for every package, which is what earlier releases did by default (with a `DeprecationWarning`). None of these is an `AC_*` command, so an action list cannot open its own gate. **Opt-in hardening.** Each of these is off until configured, and a server without them behaves as before. `JE_AUTOCONTROL_RBAC_USERS=` makes the REST API and the MCP HTTP transport resolve the bearer token to a user and authorise each route, tool and privileged `AC_*` command by role (viewer / operator / admin); the shared token is then refused. `JE_AUTOCONTROL_MCP_PATH_ROOTS` (directories separated by the OS path separator) confines every MCP tool argument that is a file path to those directories, `JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` adds the client's `roots/list`, and `JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` limits which `env://` names `ac_resolve_ref` may read. `JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` makes an endpoint verify Ed25519-signed action files without being able to sign them (`create_signing_keypair`; the private key stays on the signing machine). A remote-desktop viewer writes files pushed by a host only below `~/Downloads/AutoControl` (`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`); that one is on by default. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 718aa389f..64bdbb84f 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -277,6 +277,8 @@ je_auto_control version 除非明确指定,所有服务器都绑定在 `127.0.0.1`。 +MCP 窗口状态工具 `ac_window_minimize`、`ac_window_maximize` 与 `ac_window_restore` 在后端接受请求时返回窗口 handle,被拒绝时回复 `isError: true`(包括 handle 失效或激活请求被拒)。Win32 原始 `ShowWindow` 的先前可见性由后端规范化处理。 + **包闸门。** `AC_add_package_to_executor` 与 `AC_add_package_to_callback_executor` 会导入 Python 包并把成员注册成命令,所以从上面任何一个入口送来的动作列表都可能加载 `os` 或 `subprocess`。没有被放行的包不会加载:不在允许清单上的包在导入前就被拒绝,该动作以 `AutoControlExecuteActionException` 失败。放行包(含子模块)的方式:在 Python 里调用 `executor.allow_packages("name", …)`;对所有入口——两个 CLI、socket/REST/MCP 服务器与调度器——设置环境变量 `JE_AUTOCONTROL_ALLOWED_PACKAGES`(以逗号分隔的名称,进程启动时读取);或只对一次 CLI 执行使用 `je_auto_control run script.json --allow-package NAME`(可重复)。`executor.set_allow_arbitrary_packages(True)` 会对所有包打开闸门,也就是先前版本的默认行为(当时会发 `DeprecationWarning`)。这些都不是 `AC_*` 命令,所以动作列表不能自己打开闸门。 **可选的加固设置。** 下列各项在设置之前都是关闭的,没设置的服务器行为与先前相同。`JE_AUTOCONTROL_RBAC_USERS=<用户文件>` 让 REST API 与 MCP HTTP 传输把 bearer token 对应到用户,并按角色(viewer/operator/admin)授权每一个路由、工具与特权 `AC_*` 命令;此时共用 token 会被拒绝。`JE_AUTOCONTROL_MCP_PATH_ROOTS`(以操作系统的路径分隔符分开的目录)把每一个属于文件路径的 MCP 工具参数限制在这些目录里,`JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` 再加上 client 的 `roots/list`,`JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` 限制 `ac_resolve_ref` 可以读哪些 `env://` 名称。`JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` 让端点只能验证 Ed25519 签名的动作文件而不能签名(`create_signing_keypair`;私钥留在签名的机器上)。远程桌面的 viewer 只把 host 推来的文件写在 `~/Downloads/AutoControl` 下面(`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`);这一项默认就是开的。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 68be41304..844fc5869 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -276,6 +276,8 @@ je_auto_control version 除非明確指定,所有伺服器都綁在 `127.0.0.1`。 +MCP 視窗狀態工具 `ac_window_minimize`、`ac_window_maximize` 與 `ac_window_restore` 在後端接受要求時回傳視窗 handle,遭拒時回覆 `isError: true`(包括 handle 失效或啟用請求被拒)。Win32 原始 `ShowWindow` 的先前可見性由後端正規化處理。 + **套件閘門。** `AC_add_package_to_executor` 與 `AC_add_package_to_callback_executor` 會匯入 Python 套件並把成員註冊成命令,所以從上面任何一個入口送來的動作清單都可能載入 `os` 或 `subprocess`。沒有被放行的套件不會載入:不在允許清單上的套件在匯入前就被拒絕,該動作以 `AutoControlExecuteActionException` 失敗。放行套件(含子模組)的方式:在 Python 裡呼叫 `executor.allow_packages("name", …)`;對所有入口——兩個 CLI、socket/REST/MCP 伺服器與排程器——設定環境變數 `JE_AUTOCONTROL_ALLOWED_PACKAGES`(以逗號分隔的名稱,行程啟動時讀取);或只對一次 CLI 執行使用 `je_auto_control run script.json --allow-package NAME`(可重複)。`executor.set_allow_arbitrary_packages(True)` 會對所有套件打開閘門,也就是先前版本的預設行為(當時會發 `DeprecationWarning`)。這些都不是 `AC_*` 命令,所以動作清單不能自己打開閘門。 **選用的強化設定。** 下列各項在設定之前都是關閉的,沒設定的伺服器行為與先前相同。`JE_AUTOCONTROL_RBAC_USERS=<使用者檔>` 讓 REST API 與 MCP HTTP 傳輸把 bearer token 對到使用者,並依角色(viewer/operator/admin)授權每一個路由、工具與特權 `AC_*` 命令;此時共用 token 會被拒絕。`JE_AUTOCONTROL_MCP_PATH_ROOTS`(以作業系統的路徑分隔字元分開的目錄)把每一個屬於檔案路徑的 MCP 工具參數限制在這些目錄裡,`JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` 再加上 client 的 `roots/list`,`JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` 限制 `ac_resolve_ref` 可以讀哪些 `env://` 名稱。`JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` 讓端點只能驗證 Ed25519 簽章的動作檔而不能簽章(`create_signing_keypair`;私鑰留在簽章的機器上)。遠端桌面的 viewer 只把 host 推來的檔案寫在 `~/Downloads/AutoControl` 底下(`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`);這一項預設就是開的。 diff --git a/architecture_explore.md b/architecture_explore.md index 4d13e9825..89c2faa4c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,080 | -| 程式碼總行數 | 161,878 | +| 程式碼總行數 | 161,881 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -494,7 +494,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.9 AI / Agent / LLM -> 13 個套件、約 23,676 行。 +> 13 個套件、約 23,679 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -507,7 +507,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/cua_action/` | 204 | 標準化 computer-use 動作結構(Anthropic/OpenAI → `AC_*`) | | `utils/llm/` | 365 | 自然語言 → action list 規劃器 + Anthropic/null 後端 | | `utils/mcp_registry/` | 97 | MCP registry `server.json` 資訊清單產生(可被發現) | -| `utils/mcp_server/` | 19,161 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | +| `utils/mcp_server/` | 19,164 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | | `utils/tool_use_schema/` | 195 | 把 `AC_*` 指令匯出成 Claude/OpenAI 的 tool-use schema | | `utils/trajectory_eval/` | 132 | agent 軌跡評估:依評分規準為一次執行打分 | | `utils/vision/` | 538 | VLM 元素定位器(依描述找元素)+ Anthropic/OpenAI/null 後端 | @@ -708,7 +708,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 | | `flags.py` | 19 | 旗標讀取 `as_bool`:依拼法(`"true"`/`"yes"`/`"on"`/`"1"`)而非 Python 真值判斷,所以 JSON、CLI、MCP 傳來的 `"false"` 是關;所有 adapter 與流程指令共用。 | -#### `utils/mcp_server/`(19,161 行,680 個工具)— 最大子系統 +#### `utils/mcp_server/`(19,164 行,680 個工具)— 最大子系統 | 檔案 | 行數 | 職責 | | --- | ---: | --- | @@ -717,7 +717,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `tools/_handlers_qa.py` | 419 | 同一種 adapter,QA 主題:斷言 DSL、資料驅動、SQL/PDF/郵件/HTTP 步驟、codegen、視覺回歸、狀態機、flaky 偵測與隔離、suite runner、無障礙稽核、裝置矩陣、媒體斷言。從 `_handlers.py` 依主題拆出的第一塊(750 行上限);兩者互不引用。 | | `tools/_handlers_input.py` | 221 | 同一種 adapter,輸入主題:滑鼠、鍵盤、虛擬手把(ViGEm)。 | | `tools/_handlers_screen.py` | 344 | 同一種 adapter,螢幕主題:擷取、像素、影像與文字搜尋、螢幕錄影。 | -| `tools/_handlers_system.py` | 575 | 同一種 adapter,桌面工作階段:視窗、行程與 shell、開檔、閒置與睡眠、音量、鎖定、輸入法狀態、欄位驗證與重試、色彩對比、變更排序、元件分類、剪貼簿。 | +| `tools/_handlers_system.py` | 578 | 同一種 adapter,桌面工作階段:視窗(狀態操作依正規化的後端成敗回覆)、行程與 shell、開檔、閒置與睡眠、音量、鎖定、輸入法狀態、欄位驗證與重試、色彩對比、變更排序、元件分類、剪貼簿。 | | `tools/_handlers_runs.py` | 110 | 同一種 adapter,執行主題:executor、執行歷史、錄製、動作檔。 | | `tools/_handlers_scheduling.py` | 200 | 同一種 adapter,排程主題:排程器、觸發器、熱鍵常駐。 | | `tools/_handlers_remote.py` | 66 | 同一種 adapter,遠端桌面的 host 與 viewer。 | @@ -1082,7 +1082,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | | `gui/` | 100 | 28,704 | -| `utils/mcp_server/` | 37 | 19,161 | +| `utils/mcp_server/` | 37 | 19,164 | | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,666 | | `utils/usb/` | 17 | 4,816 | @@ -1102,5 +1102,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,652 | -| **總計** | **1,074** | **161,813** | +| **總計** | **1,074** | **161,816** | diff --git a/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst b/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst index 316f691ca..0fb5632af 100644 --- a/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst +++ b/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst @@ -39,6 +39,10 @@ Window management (Windows) ``ac_list_windows``, ``ac_focus_window``, ``ac_wait_for_window``, ``ac_close_window``, ``ac_window_move``, ``ac_window_minimize``, ``ac_window_maximize``, ``ac_window_restore``. + The three state tools return the window handle only when the backend accepts + the requested state/activation. A stale handle or refused foreground request + produces a tool result with ``isError: true``. The raw Win32 ``ShowWindow`` + return value means previous visibility and is not treated as a success flag. Semantic locators ``ac_a11y_list``, ``ac_a11y_find``, ``ac_a11y_click``, diff --git a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst index 89c85e2f1..0f05b68f7 100644 --- a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst +++ b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst @@ -38,6 +38,9 @@ list-changed 通知與 elicitation。 ``ac_list_windows``、``ac_focus_window``、``ac_wait_for_window``、 ``ac_close_window``、``ac_window_move``、``ac_window_minimize``、 ``ac_window_maximize``、``ac_window_restore``。 + 這三個狀態工具只在後端接受要求的狀態/啟用操作時回傳視窗 handle。 + handle 已失效或前景請求遭拒時,工具結果為 ``isError: true``。 + Win32 原始 ``ShowWindow`` 回傳值表示先前可見性,不會被誤當成成功旗標。 語意定位 ``ac_a11y_list``、``ac_a11y_find``、``ac_a11y_click``、 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 82f72ebdb..f0a5d17b0 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -333,3 +333,10 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Intel macOS**: both `macosx_10_9_x86_64` and `macosx_13_0_x86_64` still have no cryptography wheel satisfying the security floor (compatible versions end at 48.0.1). An actual Intel source build is still unverified. - **Local Linux verification**: Docker CLI and the docker-desktop WSL distribution exist, but the Docker Desktop Linux engine pipe is absent. No daemon or production environment was restarted. This does not replace native Wayland/CI peer evidence. - **Open items**: platform functionality, Intel source-build and native Wayland acceptance remain in `Progress.md`; this snapshot is not completion of those items. + +## U-20261018-05 · 2026-10-18 · Report refused MCP window-state operations as tool errors · #done #mcp #window + +- **What**: `ac_window_minimize`, `ac_window_maximize` and `ac_window_restore` now respect the backend's normalized success flag. Refused activation or an invalidated handle raises `AutoControlActionException`, which MCP reports as `isError: true`; accepted calls preserve the existing integer handle result. +- **Validation**: 12 new cases cover direct calls, JSON-RPC errors and Win32 previous visibility equal to zero; 212 related regression tests pass. Three-platform mypy reports zero modules with errors; strict English and Traditional Chinese Sphinx builds pass. +- **Files**: `utils/mcp_server/tools/_handlers_system.py`, MCP regression tests, three READMEs, English/Chinese MCP reference, `CHANGELOG.md`, and the measured architecture map. +- **Open items**: native desktop and mixed-DPI acceptance remain in `Progress.md`; these tests use controlled backends and user32 calls. diff --git a/docs/updates/README.md b/docs/updates/README.md index 324fdc28b..fba070a0b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-05 | 2026-10-18 | Report refused MCP window-state operations as tool errors | #done #mcp #window | [2026-10](2026-10.md) | | U-20261018-04 | 2026-10-18 | Recheck platform wheel gaps without lowering security floors | #snapshot #deps #platforms | [2026-10](2026-10.md) | | U-20261018-03 | 2026-10-18 | Bind deferred work to its registrant and isolate callbacks | #done #security #rbac | [2026-10](2026-10.md) | | U-20261018-02 | 2026-10-18 | Repair Sphinx source markup and remove completed progress history | #done #docs | [2026-10](2026-10.md) | @@ -367,7 +368,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 31 | +| [2026-10.md](2026-10.md) | 2026-10 | 32 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/mcp_server/tools/_handlers_system.py b/je_auto_control/utils/mcp_server/tools/_handlers_system.py index b995e26ff..37cdf9b88 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers_system.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers_system.py @@ -1,7 +1,7 @@ """MCP adapters for windows, processes and the rest of the desktop session. Same contract as :mod:`._handlers` -- normalise arguments and return values so -they survive the JSON-RPC boundary, with every project import lazy -- split out +they survive the JSON-RPC boundary, with platform imports kept lazy -- split out by theme because ``_handlers.py`` is over the 750-line limit. Covers window management, processes and the shell, opening files, idle and @@ -12,6 +12,7 @@ import os from typing import Any, Dict, List, Optional +from je_auto_control.utils.exception.exceptions import AutoControlActionException from je_auto_control.utils.mcp_server.tools._base import MCPContent @@ -143,10 +144,12 @@ def window_move(title_substring: str, x: int, y: int, def _show_command(title_substring: str, case_sensitive: bool, cmd_show: int) -> int: - """Resolve the window then call ShowWindow with the given cmd.""" + """Return the resolved handle only when the requested state is accepted.""" from je_auto_control.windows.window import windows_window_manage as wm hwnd = _resolve_window_hwnd(title_substring, bool(case_sensitive)) - wm.show_window(hwnd, int(cmd_show)) + if not wm.show_window(hwnd, int(cmd_show)): + raise AutoControlActionException( + f"could not apply window state/activation {cmd_show} to window {hwnd}") return hwnd diff --git a/test/unit_test/headless/test_mcp_server.py b/test/unit_test/headless/test_mcp_server.py index 94738d6d2..45f9f7846 100644 --- a/test/unit_test/headless/test_mcp_server.py +++ b/test/unit_test/headless/test_mcp_server.py @@ -1392,12 +1392,13 @@ def test_window_minimize_uses_show_command_six(monkeypatch): def fake_show(hwnd, cmd_show): seen["call"] = (int(hwnd), int(cmd_show)) + return True from je_auto_control.windows.window import windows_window_manage monkeypatch.setattr(windows_window_manage, "show_window", fake_show) by_name = {tool.name: tool for tool in build_default_tool_registry()} - by_name["ac_window_minimize"].invoke({"title_substring": "Notepad"}) + assert by_name["ac_window_minimize"].invoke({"title_substring": "Notepad"}) == 456 assert seen["call"] == (456, 6) diff --git a/test/unit_test/headless/test_mcp_window_state_outcome.py b/test/unit_test/headless/test_mcp_window_state_outcome.py new file mode 100644 index 000000000..533d69f8b --- /dev/null +++ b/test/unit_test/headless/test_mcp_window_state_outcome.py @@ -0,0 +1,79 @@ +"""Window-state MCP tools return a handle only after the backend accepts them.""" +import json +import sys +from types import SimpleNamespace + +import pytest + +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.mcp_server.server import MCPServer +from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + +TOOLS = (("ac_window_minimize", 6), ("ac_window_maximize", 3), ("ac_window_restore", 9)) + + +@pytest.fixture +def backend(monkeypatch): + from je_auto_control.windows import window as package + from je_auto_control.wrapper import auto_control_window + + calls = [] + accepted = [True] + + def show(hwnd, command): + calls.append((hwnd, command)) + return accepted[0] + + # The package itself has no Win32 imports. Replace its module attribute, + # so these contract tests run on Linux/macOS without loading a Windows DLL. + monkeypatch.setattr(package, "windows_window_manage", SimpleNamespace(show_window=show), raising=False) + monkeypatch.setattr(auto_control_window, "find_window", lambda title, case_sensitive=False: (123, title)) + return accepted, calls + + +@pytest.mark.parametrize("name,command", TOOLS) +def test_accepted_window_state_returns_the_original_handle(name, command, backend): + _, calls = backend + tool = next(tool for tool in build_default_tool_registry() if tool.name == name) + assert tool.invoke({"title_substring": "Test window", "case_sensitive": True}) == 123 + assert calls == [(123, command)] + + +@pytest.mark.parametrize("name,command", TOOLS) +def test_refused_window_state_raises_a_framework_error(name, command, backend): + accepted, calls = backend + accepted[0] = False + tool = next(tool for tool in build_default_tool_registry() if tool.name == name) + with pytest.raises(AutoControlException, match="could not apply"): + tool.invoke({"title_substring": "Test window"}) + assert calls == [(123, command)] + + +@pytest.mark.parametrize("name,_command", TOOLS) +def test_jsonrpc_reports_backend_refusal_as_tool_error(name, _command, backend): + accepted, _ = backend + accepted[0] = False + tool = next(tool for tool in build_default_tool_registry() if tool.name == name) + server = MCPServer(tools=[tool]) + reply = json.loads(server.handle_line(json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": {"title_substring": "Test window"}}, + }))) + assert reply["result"]["isError"] is True + assert "could not apply" in reply["result"]["content"][0]["text"] + + +@pytest.mark.skipif(sys.platform != "win32", reason="real Win32 adapter, with controlled user32 calls") +@pytest.mark.parametrize("name,command", TOOLS) +def test_previous_visibility_zero_is_not_mistaken_for_backend_failure(name, command, monkeypatch): + from je_auto_control.windows.window import windows_window_manage + from je_auto_control.wrapper import auto_control_window + + monkeypatch.setattr(windows_window_manage, "_user32", SimpleNamespace( + IsWindow=lambda hwnd: True, + ShowWindow=lambda hwnd, flag: 0, + SetForegroundWindow=lambda hwnd: True, + )) + monkeypatch.setattr(auto_control_window, "find_window", lambda *_args, **_kwargs: (123, "Test")) + tool = next(tool for tool in build_default_tool_registry() if tool.name == name) + assert tool.invoke({"title_substring": "Test"}) == 123 From 00e2f981ec6a953c60f98940caa2427093d0a31b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 20:47:22 +0800 Subject: [PATCH 006/294] Isolate CLI, planner and state-machine run variables --- CHANGELOG.md | 5 + Progress.md | 3 +- README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 4 + architecture_explore.md | 20 +-- docs/source/Eng/doc/cli/cli_doc.rst | 5 +- .../Eng/doc/new_features/new_features_doc.rst | 10 ++ docs/source/Zh/doc/cli/cli_doc.rst | 4 + .../Zh/doc/new_features/new_features_doc.rst | 8 + docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- je_auto_control/cli.py | 15 +- je_auto_control/utils/llm/planner.py | 11 +- .../utils/script_vars/execution.py | 5 +- je_auto_control/utils/state_machine/engine.py | 15 +- .../test_remaining_execution_scopes.py | 157 ++++++++++++++++++ 18 files changed, 248 insertions(+), 31 deletions(-) create mode 100644 test/unit_test/headless/test_remaining_execution_scopes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9edc9aa..32d3710e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- CLI `run` and `--dry-run` now discard per-run variables, including `--var` + seeds, on success or error. Top-level LLM planner and state-machine runs use + fresh variables; nested calls reuse the enclosing scope. State-machine + `on_enter` now dispatches standard single actions correctly as well as batches. + - MCP `ac_window_minimize`, `ac_window_maximize` and `ac_window_restore` return a tool error when the backend refuses the state/activation request, instead of reporting the handle as success. Successful calls still return diff --git a/Progress.md b/Progress.md index 565c9a255..081c67345 100644 --- a/Progress.md +++ b/Progress.md @@ -222,8 +222,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 -- **變數範圍**:`je_auto_control run --dry-run --var`、`utils/llm/planner.py` 與狀態機預設 runner 仍用行程層級的範圍。 - free-threaded 版(執行緒繼承 context)上,一次執行裡另開的執行緒會看到該次執行的範圍,沒測。 +- **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 - **套件閘門**:`execute_action` 會先驗完所有指令名稱才執行,所以「載入套件」與「用它的指令」寫在同一份清單裡會以 unknown command 失敗,與閘門無關(既有限制)。 diff --git a/README.md b/README.md index aafbf295a..5c8b52e4a 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. ## Command-line interface +Each `je_auto_control run` invocation, including `--dry-run` and runs without `--var`, gets a fresh variable scope. Top-level `run_from_description` and `StateMachine.run()` also isolate variables while sharing them across their own actions and states; nested calls reuse an existing execution scope. A caller-owned `Executor()` keeps its own variables. + ```bash je_auto_control run script.json [--var name=value] [--dry-run] [--allow-package NAME] je_auto_control validate script.json # alias: lint diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 64bdbb84f..8cea58324 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -245,6 +245,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" ## 命令行界面 +每次 `je_auto_control run`(包括 `--dry-run` 与未带 `--var`)使用独立变量作用域。顶层 `run_from_description` 与 `StateMachine.run()` 也会隔离变量,同一次执行内的动作与状态仍可共享;嵌套调用沿用已有的执行作用域。自行创建的 `Executor()` 保留自己的变量。 + ```bash je_auto_control run script.json [--var name=value] [--dry-run] [--allow-package NAME] je_auto_control validate script.json # 别名:lint diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 844fc5869..2fa687e40 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -244,6 +244,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" ## 命令列介面 +每次 `je_auto_control run`(包括 `--dry-run` 與未帶 `--var`)使用獨立變數範圍。頂層 `run_from_description` 與 `StateMachine.run()` 也會隔離變數,同一次執行內的動作與狀態仍可共享;巢狀呼叫沿用已有的執行範圍。自行建立的 `Executor()` 保留自己的變數。 + ```bash je_auto_control run script.json [--var name=value] [--dry-run] [--allow-package NAME] je_auto_control validate script.json # 別名:lint diff --git a/architecture.md b/architecture.md index 6491f4c24..a4f8a6b2f 100644 --- a/architecture.md +++ b/architecture.md @@ -93,6 +93,10 @@ re-reads that store for current roles and revocations, without retaining bearer tokens. Trusted local registrations bind no RBAC identity. Observer/watchdog callbacks also use script jobs' fresh variables and per-run failure deltas, preserving any earlier failures recorded by the caller. +CLI runs and previews seed a fresh variable scope even without `--var`. +Top-level LLM planner and FSM runs also open a scope; nested invocations reuse +an existing one so their actions and states share run-local values. Explicit +`Executor()` objects retain their own variables; FSM `context` is separate. **C. Record → edit → generate code** diff --git a/architecture_explore.md b/architecture_explore.md index 89c2faa4c..de43ff063 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,080 | -| 程式碼總行數 | 161,881 | +| 程式碼總行數 | 161,893 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -156,7 +156,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | --- | ---: | --- | | `je_auto_control/__init__.py` | 1,994 | **套件門面**。集中匯入並再匯出 1,264 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | | `je_auto_control/__main__.py` | 98 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | -| `je_auto_control/cli.py` | 372 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | +| `je_auto_control/cli.py` | 367 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | | `je_auto_control/api/core.py` | 19 | **穩定無頭 API 門面**:只暴露 `execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、`create_failure_bundle`、`failure_bundle_on_error`、`FailureBundleOptions`。mypy 型別契約以此為起點,現已擴到整包(見「設定基線」)。 | | `je_auto_control/utils/cli_output.py` | 44 | 本套件命令列工具與 stdio 伺服器的標準串流:不論碼頁一律 UTF-8。 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 24 個套件、約 15,224 行。 +> 24 個套件、約 15,234 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -294,9 +294,9 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/project/` | 183 | 專案腳手架:建立目錄結構與範本 action 檔 | | `utils/recording_edit/` | 165 | 不重錄的前提下裁切/過濾/縮放已錄製的 action list | | `utils/saga/` | 103 | Saga 協調器:失敗時以 LIFO 補償動作回滾 | -| `utils/script_vars/` | 287 | 執行期變數作用域與 `${var}` / `${secrets.*}` 插值 | +| `utils/script_vars/` | 288 | 執行期變數作用域與 `${var}` / `${secrets.*}` 插值 | | `utils/skill_library/` | 145 | 具名可重用 action 序列(skill)的持久化倉庫 | -| `utils/state_machine/` | 268 | 宣告式有限狀態機驅動 action JSON | +| `utils/state_machine/` | 277 | 宣告式有限狀態機驅動 action JSON;預設動作跨狀態共享單次執行範圍 | | `utils/stubs/` | 311 | 為 `AC_*` 指令面產生型別 stub | | `utils/test_record/` | 70 | 全域測試紀錄單例,記錄每個動作的參數與例外 | | `utils/work_queue/` | 280 | 交易式工作佇列(dispatcher/performer),支撐大量批次執行 | @@ -494,7 +494,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.9 AI / Agent / LLM -> 13 個套件、約 23,679 行。 +> 13 個套件、約 23,686 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -505,7 +505,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/agent_trace/` | 172 | agent 可觀測性:OpenTelemetry GenAI 慣例的 LLM span | | `utils/cost_telemetry/` | 345 | 每次呼叫的 LLM 成本遙測:token 數 + 估算美金 | | `utils/cua_action/` | 204 | 標準化 computer-use 動作結構(Anthropic/OpenAI → `AC_*`) | -| `utils/llm/` | 365 | 自然語言 → action list 規劃器 + Anthropic/null 後端 | +| `utils/llm/` | 372 | 自然語言 → action list 規劃器 + Anthropic/null 後端;規劃執行隔離頂層變數並沿用巢狀範圍 | | `utils/mcp_registry/` | 97 | MCP registry `server.json` 資訊清單產生(可被發現) | | `utils/mcp_server/` | 19,164 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | | `utils/tool_use_schema/` | 195 | 把 `AC_*` 指令匯出成 Claude/OpenAI 的 tool-use schema | @@ -1086,7 +1086,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,666 | | `utils/usb/` | 17 | 4,816 | -| `je_auto_control/`(頂層 3 檔) | 3 | 2,464 | +| `je_auto_control/`(頂層 3 檔) | 3 | 2,459 | | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | @@ -1101,6 +1101,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,652 | -| **總計** | **1,074** | **161,816** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,669 | +| **總計** | **1,074** | **161,828** | diff --git a/docs/source/Eng/doc/cli/cli_doc.rst b/docs/source/Eng/doc/cli/cli_doc.rst index 808f10132..d15089916 100644 --- a/docs/source/Eng/doc/cli/cli_doc.rst +++ b/docs/source/Eng/doc/cli/cli_doc.rst @@ -25,7 +25,10 @@ Run a script ``--var name=value`` is parsed as JSON when the value parses, otherwise it is treated as a plain string. ``--dry-run`` records every action -through the executor without invoking any side effects. +through the executor without invoking any side effects. Each invocation uses +an independent variable scope, with or without ``--var``; seeds and action-written +variables are discarded on success or error, including when called repeatedly +from the same Python process. ``--allow-package NAME`` (repeatable) puts a package, and its submodules, on the package gate's allowlist for this run: ``AC_add_package_to_executor`` and diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 18daec7e2..6c9dc2990 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -403,6 +403,16 @@ that called them, also inside an ``AC_parallel`` branch; a branch starts from a copy of its parent's variables and its own writes stay in the branch. An executor you construct yourself (``Executor()``) always owns its scope. +CLI ``run`` (including ``--dry-run``), top-level ``run_from_description`` and +``StateMachine.run()`` also isolate variables. A planner or FSM called inside an +existing ``execution_scope`` shares it, so variables survive across the plan's +actions and the machine's states, then end with the outer run. The FSM's mutable +``context`` remains a separate caller-owned scratch pad. + +New threads inherit context when Python's thread context inheritance is enabled +(the free-threaded default), or when the caller explicitly copies the context. +Use a fresh ``execution_scope`` in an independent worker to isolate its variables. + :: import je_auto_control as ac diff --git a/docs/source/Zh/doc/cli/cli_doc.rst b/docs/source/Zh/doc/cli/cli_doc.rst index 3444fd8a3..501c8da1d 100644 --- a/docs/source/Zh/doc/cli/cli_doc.rst +++ b/docs/source/Zh/doc/cli/cli_doc.rst @@ -4,6 +4,10 @@ AutoControl 可以直接從命令列執行自動化腳本。 +子命令 ``python -m je_auto_control.cli run script.json`` 可加上 ``--var name=value`` +與 ``--dry-run``。每次呼叫都有獨立變數範圍,不論是否傳入 ``--var``;種子與動作寫入的 +變數在成功或出錯後都會捨棄,包含在同一個 Python 行程中重複呼叫與預演。 + 執行單一動作檔案 ================ diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index 2792a7fd8..f0e7fe8e0 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -375,6 +375,14 @@ Variables 分頁顯示的、以及從 GUI 執行的腳本用的就是這個範 範圍裡,在 ``AC_parallel`` 分支內也一樣;分支從父層變數的一份複本開始, 自己寫入的值留在分支內。自行建立的 executor(``Executor()``)永遠擁有自己的範圍。 +CLI ``run`` (含 ``--dry-run``)、頂層 ``run_from_description`` 與 ``StateMachine.run()`` +也會隔離變數。規劃器或狀態機若在既有 ``execution_scope`` 內呼叫,會共用該範圍, +所以變數可跨同一次規劃的動作與狀態機的狀態,隨外層執行結束而捨棄。 +狀態機可變的 ``context`` 仍是獨立、由呼叫端持有的暫存區。 + +Python 啟用執行緒 context 繼承(free-threaded 預設)或呼叫端明確複製 context 時, +新執行緒會繼承變數範圍;獨立 worker 應在自己的 ``execution_scope`` 中執行以隔離變數。 + :: diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index f0a5d17b0..a904c9277 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -340,3 +340,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 12 new cases cover direct calls, JSON-RPC errors and Win32 previous visibility equal to zero; 212 related regression tests pass. Three-platform mypy reports zero modules with errors; strict English and Traditional Chinese Sphinx builds pass. - **Files**: `utils/mcp_server/tools/_handlers_system.py`, MCP regression tests, three READMEs, English/Chinese MCP reference, `CHANGELOG.md`, and the measured architecture map. - **Open items**: native desktop and mixed-DPI acceptance remain in `Progress.md`; these tests use controlled backends and user32 calls. + +## U-20261018-06 · 2026-10-18 · Isolate CLI previews, planner runs and state-machine variables · #done #executor #variables + +- **What**: CLI `run` always opens a fresh variable scope, including previews, empty seeds and failures. Top-level `run_from_description` and `StateMachine.run()` also isolate their script variables, while nested helpers reuse the enclosing run's scope. FSM states share run-local values; caller-owned executor variables and the FSM's separate context retain their ownership. +- **Correctness**: the FSM's default bridge now distinguishes a standard single action from a nested action batch, so real `on_enter` commands execute rather than fail schema validation. Corrected variable-scope documentation about Python thread context inheritance. +- **Validation**: 15 new cases cover all CLI seed/preview combinations, repeated and concurrent runs, cross-state values, nested calls and restoration after errors; a 128-test related regression run and the subsequent 15-case focused run pass; 166 flow-control, documentation and cross-project checks also pass. Three-platform mypy reports zero modules with errors; strict English and Traditional Chinese Sphinx builds pass. +- **Files**: `cli.py`, `utils/llm/planner.py`, `utils/state_machine/engine.py`, variable-scope documentation, regression tests, three READMEs, Sphinx references, changelog and architecture maps. +- **Open items**: native free-threaded concurrency verification remains in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index fba070a0b..ade038e97 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-06 | 2026-10-18 | Isolate CLI previews, planner runs and state-machine variables | #done #executor #variables | [2026-10](2026-10.md) | | U-20261018-05 | 2026-10-18 | Report refused MCP window-state operations as tool errors | #done #mcp #window | [2026-10](2026-10.md) | | U-20261018-04 | 2026-10-18 | Recheck platform wheel gaps without lowering security floors | #snapshot #deps #platforms | [2026-10](2026-10.md) | | U-20261018-03 | 2026-10-18 | Bind deferred work to its registrant and isolate callbacks | #done #security #rbac | [2026-10](2026-10.md) | @@ -368,7 +369,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 32 | +| [2026-10.md](2026-10.md) | 2026-10 | 33 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/cli.py b/je_auto_control/cli.py index ed6e99c65..2ba2fbbba 100644 --- a/je_auto_control/cli.py +++ b/je_auto_control/cli.py @@ -64,26 +64,21 @@ def _allow_packages(names: Optional[Sequence[str]]) -> None: def cmd_run(args: argparse.Namespace) -> int: + """Execute or preview one script in its own variable scope.""" from je_auto_control.utils.executor.action_executor import ( - execute_action, execute_action_with_vars, recorded_failures, - reset_recorded_failures, + executor, recorded_failures, reset_recorded_failures, ) from je_auto_control.utils.json.json_file import read_executable_action_json + from je_auto_control.utils.script_vars.execution import execution_scope actions = read_executable_action_json(args.script) variables = _parse_vars(args.var) _allow_packages(args.allow_package) reset_recorded_failures() - if args.dry_run: - from je_auto_control.utils.executor.action_executor import executor + with execution_scope(variables): # Seeded, not interpolated over the whole tree: that failed on a loop # body's ${item} ("Unknown variable") and would print ${secrets.X} # resolved into the dry-run keys. - executor.variables.update_many(variables) - result = executor.execute_action(actions, dry_run=True) - elif variables: - result = execute_action_with_vars(actions, variables) - else: - result = execute_action(actions) + result = executor.execute_action(actions, dry_run=args.dry_run) json.dump(result, sys.stdout, indent=2, default=str, ensure_ascii=False) sys.stdout.write("\n") # Failed actions are recorded and the run goes on; exiting 0 anyway left diff --git a/je_auto_control/utils/llm/planner.py b/je_auto_control/utils/llm/planner.py index b4c844223..697f9babd 100644 --- a/je_auto_control/utils/llm/planner.py +++ b/je_auto_control/utils/llm/planner.py @@ -7,6 +7,7 @@ """ import json import re +from contextlib import nullcontext from typing import Any, Dict, Iterable, List, Optional from je_auto_control.utils.exception.exceptions import AutoControlActionException @@ -14,6 +15,7 @@ from je_auto_control.utils.llm.backends import ( LLMBackend, LLMNotAvailableError, get_backend, ) +from je_auto_control.utils.script_vars.execution import current_scope, execution_scope _SYSTEM_PROMPT = ( "You translate plain-language automation instructions into a strict " @@ -73,7 +75,11 @@ def run_from_description(description: str, backend: Optional[LLMBackend] = None, model: Optional[str] = None, max_tokens: int = 2048) -> Dict[str, Any]: - """Plan a description and execute it on ``executor`` in one call.""" + """Plan and execute in a fresh run, reusing an enclosing execution scope. + + The module executor shares variables across this plan's actions, then drops + them on return or failure. Caller-owned executors retain their own scope. + """ actions = plan_actions( description, known_commands=executor.known_commands(), @@ -84,7 +90,8 @@ def run_from_description(description: str, ) # A top-level run: ``_validated=True`` would mark it as a nested body, # and a stray AC_break from the model would escape as a raw LoopBreak. - record = executor.execute_action(actions) + with execution_scope() if current_scope() is None else nullcontext(): + record = executor.execute_action(actions) return {"actions": actions, "record": record} diff --git a/je_auto_control/utils/script_vars/execution.py b/je_auto_control/utils/script_vars/execution.py index a43df3a5a..159fec1ca 100644 --- a/je_auto_control/utils/script_vars/execution.py +++ b/je_auto_control/utils/script_vars/execution.py @@ -9,8 +9,9 @@ :func:`execution_scope` opens a fresh scope for one run and makes it the module executor's scope for as long as the ``with`` block lasts. The binding is a :class:`contextvars.ContextVar`, so it is per thread (and per asyncio -task): two server threads running at once each see their own scope, and a -thread started inside the block does not inherit it. Code that hands work to +task): two server threads running at once each see their own scope. New threads +inherit it only when Python's thread context inheritance is enabled (the +free-threaded default) or a caller explicitly copies the context. Code handing work to another thread on behalf of the run -- the DAG runner's pool -- captures the scope first and re-binds it there with :func:`bound_scope`. diff --git a/je_auto_control/utils/state_machine/engine.py b/je_auto_control/utils/state_machine/engine.py index 25cd757a0..aa0750a75 100644 --- a/je_auto_control/utils/state_machine/engine.py +++ b/je_auto_control/utils/state_machine/engine.py @@ -2,9 +2,11 @@ from __future__ import annotations import time +from contextlib import nullcontext from typing import Any, Callable, Dict, Mapping, Optional from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.script_vars.execution import current_scope, execution_scope # AutoControlException so `except AutoControlException` boundaries catch a @@ -58,8 +60,14 @@ def run(self) -> Dict[str, Any]: Returns ``{"final_state": name, "steps": N, "elapsed_s": S}``. Raises :class:`StateMachineError` on budget exhaustion or when - a state has no fireable transition. + a state has no fireable transition. Default-executor actions share a + fresh variable scope for this run, or reuse an enclosing run's scope. """ + with execution_scope() if current_scope() is None else nullcontext(): + return self._run() + + def _run(self) -> Dict[str, Any]: + """Drive states inside the run's variable scope.""" current = self._spec["initial"] if current not in self._states: raise StateMachineError(f"initial state {current!r} undefined") @@ -148,9 +156,10 @@ def _validate_spec(spec: Mapping[str, Any]) -> None: def _default_execute_action(action: Any) -> Any: - """Lazy bridge to the main executor; isolates test imports.""" + """Lazy bridge accepting a single action or a nested action batch.""" from je_auto_control.utils.executor.action_executor import execute_action - return execute_action([action] if not isinstance(action, list) else action) + batch = isinstance(action, list) and (not action or isinstance(action[0], list)) + return execute_action(action if batch else [action]) def _after_ok(transition: Mapping[str, Any], _context: Mapping[str, Any], diff --git a/test/unit_test/headless/test_remaining_execution_scopes.py b/test/unit_test/headless/test_remaining_execution_scopes.py new file mode 100644 index 000000000..76006b0e3 --- /dev/null +++ b/test/unit_test/headless/test_remaining_execution_scopes.py @@ -0,0 +1,157 @@ +"""CLI, planner and FSM boundaries isolate runs while nested helpers share them.""" +import json +import threading +from types import SimpleNamespace + +import pytest + +from je_auto_control.cli import main +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.llm.planner import run_from_description +from je_auto_control.utils.script_vars.execution import current_scope, execution_scope +from je_auto_control.utils.state_machine import StateMachine, StateMachineError + +PROBE = "AC_remaining_scope_probe" +SET = ["AC_set_var", {"name": "owner", "value": "child"}] +READ = [PROBE, {"value": "${owner}"}] + + +@pytest.fixture +def seen(monkeypatch): + values = [] + saved = executor.variables.as_dict() + executor.variables.clear() + monkeypatch.setitem(executor.event_dict, PROBE, lambda value: values.append(value)) + yield values + executor.variables.clear() + executor.variables.update_many(saved) + + +def _plan(actions): + backend = SimpleNamespace(available=True, complete=lambda *_args, **_kwargs: json.dumps(actions)) + return run_from_description("run variable actions", executor, backend=backend) + + +def _machine(actions, *, finish=True): + return StateMachine({ + "initial": "first", + "states": { + "first": {"on_enter": actions[:1], "transitions": [{"go_to": "last"}]}, + "last": {"on_enter": actions[1:], "final": finish}, + }, + }) + + +@pytest.mark.parametrize("dry_run", [False, True]) +@pytest.mark.parametrize("seed", [False, True]) +def test_cli_run_always_uses_a_fresh_scope(seen, tmp_path, monkeypatch, capsys, dry_run, seed): + executor.variables.set("existing", "process") + snapshots = [] + original = executor.execute_action + + def execute(actions, **kwargs): + snapshots.append(executor.variables.as_dict()) + return original(actions, **kwargs) + + monkeypatch.setattr(executor, "execute_action", execute) + path = tmp_path / "run.json" + path.write_text(json.dumps([SET, READ]), encoding="utf-8") + args = ["run", str(path)] + (["--dry-run"] if dry_run else []) + if seed: + args += ["--var", "owner=seed"] + assert main(args) == 0 + capsys.readouterr() + assert snapshots == ([{"owner": "seed"}] if seed else [{}]) + assert executor.variables.as_dict() == {"existing": "process"} + assert seen == ([] if dry_run else ["child"]) + assert current_scope() is None + + +@pytest.mark.parametrize("run", [_plan, lambda actions: _machine(actions).run()]) +def test_top_level_helpers_isolate_runs_but_share_actions_within_one_run(seen, run): + executor.variables.set("owner", "process") + run([SET, READ]) + run([READ]) + assert seen == ["child"] + assert executor.variables.as_dict() == {"owner": "process"} + assert current_scope() is None + + +@pytest.mark.parametrize("run", [_plan, lambda actions: _machine(actions).run()]) +def test_nested_helpers_reuse_the_enclosing_scope(seen, run): + with execution_scope({"owner": "parent"}) as scope: + run([READ, SET, READ]) + assert current_scope() is scope + assert scope.get("owner") == "child" + assert seen == ["parent", "child"] + assert executor.variables.as_dict() == {} + + +def test_reusing_a_state_machine_starts_a_new_scope_each_time(seen): + machine = _machine([READ]) + with execution_scope({"owner": "parent"}): + machine.run() + machine.run() + assert seen == ["parent"] + + +def test_fsm_error_restores_the_previous_scope(seen): + executor.variables.set("owner", "process") + with pytest.raises(StateMachineError, match="no transition"): + _machine([SET, READ], finish=False).run() + assert seen == ["child"] + assert executor.variables.as_dict() == {"owner": "process"} + assert current_scope() is None + + +def test_planner_executor_error_restores_the_previous_scope(seen, monkeypatch): + executor.variables.set("owner", "process") + + def fail(_actions): + executor.variables.set("owner", "child") + raise StateMachineError("execution failed") + + monkeypatch.setattr(executor, "execute_action", fail) + with pytest.raises(StateMachineError, match="execution failed"): + _plan([SET]) + assert executor.variables.as_dict() == {"owner": "process"} + assert current_scope() is None + + +def test_cli_error_discards_dry_run_seeds(seen, tmp_path, capsys): + path = tmp_path / "unknown.json" + path.write_text('[["AC_no_such_command"]]', encoding="utf-8") + assert main(["run", str(path), "--dry-run", "--var", "owner=seed"]) == 1 + capsys.readouterr() + assert executor.variables.as_dict() == {} + assert current_scope() is None + + +@pytest.mark.parametrize("run", [_plan, lambda actions: _machine(actions).run()]) +def test_concurrent_helper_runs_do_not_share_variables(seen, monkeypatch, run): + barrier = threading.Barrier(2, timeout=5) + monkeypatch.setitem(executor.event_dict, "AC_remaining_scope_meet", lambda: barrier.wait()) + errors = [] + + def worker(owner): + try: + run([["AC_set_var", {"name": "owner", "value": owner}], + ["AC_remaining_scope_meet"], READ]) + except Exception as error: # reason: report worker failures to the asserting test thread + errors.append(error) + + threads = [threading.Thread(target=worker, args=(owner,)) for owner in ("left", "right")] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + assert not any(thread.is_alive() for thread in threads) + assert errors == [] + assert sorted(seen) == ["left", "right"] + assert executor.variables.as_dict() == {} + + +def test_fsm_default_runner_retains_nested_action_batches(seen): + _machine([[SET, READ]]).run() + assert seen == ["child"] + assert executor.variables.as_dict() == {} From 7d479580afe9421bf373d4fdbf77e6a00a9b8db2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 20:54:42 +0800 Subject: [PATCH 007/294] Release factory-built GUI panels on explicit tab close --- CHANGELOG.md | 6 + Progress.md | 2 +- README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 2 +- architecture_explore.md | 10 +- .../Eng/doc/new_features/new_features_doc.rst | 7 + .../Zh/doc/new_features/new_features_doc.rst | 5 + docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- je_auto_control/gui/main_widget.py | 30 +++- je_auto_control/gui/tab_registry.py | 62 +++++++- .../headless/test_gui_registry_close.py | 138 ++++++++++++++++++ 14 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 test/unit_test/headless/test_gui_registry_close.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 32d3710e5..ff5d09486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- The GUI tab close button now releases factory-built feature panels and their + destruction-bound resources; reopening creates a fresh panel. View-menu hiding + and `hide_tab` retain state. Explicit `TabRegistry.open/close` and `close_tab` + APIs preserve metadata, respect rejected closes, and retain legacy owner-built + forms and Remote Desktop until their lifecycle migration. + - CLI `run` and `--dry-run` now discard per-run variables, including `--var` seeds, on success or error. Top-level LLM planner and state-machine runs use fresh variables; nested calls reuse the enclosing scope. State-machine diff --git a/Progress.md b/Progress.md index 081c67345..5a9187028 100644 --- a/Progress.md +++ b/Progress.md @@ -15,7 +15,7 @@ 計畫 B(動作日誌、自愈量測、codegen)、C(持久化同步與遠端 session;其中 C4 的擁有者模型已做)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 -- **F1 的 `TabRegistry.open/close` 介面與 `close` 釋放訂閱**:現在關閉分頁只是從分頁列移除,widget 留著。 +- **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 - **F2 其餘**:計畫指定的 `WorkspaceShell` 介面、右側可收合詳情,以及各面板的空白/錯誤/permission states 還沒全面交付。 - **F3**(共用 worker、取消、關閉時不碰已銷毀物件、`webrtc_panel.py` 拆分)與 **F4**(啟動/記憶體基準、mixed-DPI、 功能對等測試)尚未開始。 diff --git a/README.md b/README.md index 5c8b52e4a..a1b81f89b 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ Optional extras, installed only when you need them: | `fuzzy` / `locale` | `rapidfuzz` matching, `babel` locale parsing | | `s3` / `audio` | S3 artifact store, system volume control | +Closing a factory-built feature with the tab cross releases its widget, subscriptions and timers on the next Qt event-loop turn; reopening starts a new panel. **View → Tabs** and the embedding `hide_tab` API only hide it and preserve state. `tab_registry.open/close` and `close_tab` expose explicit lifecycle operations. Legacy owner-built forms and Remote Desktop remain reusable on close while their lifecycle migration is pending. + **Windows on arm64** installs and runs, minus what upstream cannot ship there: neither `opencv-python` nor `cryptography` publishes a `win_arm64` wheel. So `find_image*`, `screenshot()` (the OpenCV/BGR one — the Pillow diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 8cea58324..3c3d96491 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -123,6 +123,8 @@ pip install je_auto_control[gui] # 加上 PySide6 桌面应用 | `fuzzy` / `locale` | `rapidfuzz` 模糊匹配、`babel` 区域解析 | | `s3` / `audio` | S3 制品存储、系统音量控制 | +使用标签页关闭按钮关闭 factory 创建的功能面板时,下一次 Qt event loop 会释放 widget、订阅与计时器;重新打开会创建新面板。**View → Tabs** 与嵌入用的 `hide_tab` 只隐藏并保留状态。`tab_registry.open/close` 与 `close_tab` 提供明确的生命周期接口。既有的所有者表单与 Remote Desktop 在生命周期迁移完成前,关闭后仍保留以供重用。 + **Windows arm64** 装得起来也跑得起来,少的是上游在那里发不出来的那些: `opencv-python` 与 `cryptography` 都没发 `win_arm64` wheel。所以 `find_image*`、 `screenshot()`(OpenCV/BGR 那一支——Pillow 截图仍可用)、密钥金库、动作文件加密、 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 2fa687e40..6b1e63875 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -122,6 +122,8 @@ pip install je_auto_control[gui] # 加上 PySide6 桌面應用程式 | `fuzzy` / `locale` | `rapidfuzz` 模糊比對、`babel` 地區解析 | | `s3` / `audio` | S3 產出物儲存、系統音量控制 | +以分頁關閉鈕關閉 factory 建立的功能面板時,下一次 Qt event loop 會釋放 widget、訂閱與計時器;重新開啟會建立新面板。**View → Tabs** 與嵌入用的 `hide_tab` 只隱藏並保留狀態。`tab_registry.open/close` 與 `close_tab` 提供明確的生命週期介面。既有的擁有者表單與 Remote Desktop 在生命週期遷移完成前,關閉後仍保留以供重用。 + **Windows arm64** 裝得起來也跑得起來,少的是上游在那裡發不出來的那些: `opencv-python` 與 `cryptography` 都沒發 `win_arm64` wheel。所以 `find_image*`、 `screenshot()`(OpenCV/BGR 那一支——Pillow 截圖仍可用)、秘密金庫、動作檔加密、 diff --git a/architecture.md b/architecture.md index a4f8a6b2f..45be86f5f 100644 --- a/architecture.md +++ b/architecture.md @@ -29,7 +29,7 @@ entry points → execution core (`utils/executor/`) → headless capabilities (` | `je_auto_control/wrapper/` | Platform-neutral API (`auto_control_mouse/keyboard/screen/image/record/window.py`); `platform_wrapper.py` picks the backend; `backend_contract.py` types the seam; `window_backends/`. | | `je_auto_control/{windows,osx,linux_with_x11,linux_wayland}/` | Desktop OS backends; only the running OS's backend is imported. | | `je_auto_control/{android,ios}/` | Mobile device control (adb / uiautomator2, WebDriverAgent). | -| `je_auto_control/gui/` | Optional PySide6 GUI (`[gui]` extra): `main_window.py` (menus, navigation dock, theme), `main_widget.py` (the tabbed workspace), `workspace.py` (scroll containers preserving original page identity, vector close buttons), `workspace_settings.py` (validated per-user QSettings, injectable in tests), the tab table `tab_registry.py` (tabs are built on first open), `navigation.py`, `theme.py`, `script_builder/`, `remote_desktop/`, `language_wrapper/`. | +| `je_auto_control/gui/` | Optional PySide6 GUI (`[gui]` extra): `main_window.py` (menus, navigation dock, theme), `main_widget.py` (the tabbed workspace), `workspace.py` (scroll containers preserving original page identity, vector close buttons), `workspace_settings.py` (validated per-user QSettings, injectable in tests), the Qt-free `TabRegistry` in `tab_registry.py` (open caches panels; close detaches and schedules owned panels for destruction, retaining metadata and legacy forms), `navigation.py`, `theme.py`, `script_builder/`, `remote_desktop/`, `language_wrapper/`. | | `autocontrol-lsp/` | Separate distribution: language server for `AC_*` action JSON, plus a `vscode/` client. | | `test/` | `unit_test/headless/` (CI gate), `unit_test/flow_control/`, `integrated_test/`, `gui_test/`, `manual_test/`, `verify/`. | | `docs/` | Sphinx docs, `API_LIFECYCLE.md`, `CAPABILITY_MATRIX.md`. | diff --git a/architecture_explore.md b/architecture_explore.md index de43ff063..055e9fdeb 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,080 | -| 程式碼總行數 | 161,893 | +| 程式碼總行數 | 161,971 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -882,8 +882,8 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | --- | ---: | --- | | `gui/__init__.py` | 25 | `start_autocontrol_gui()`:**唯一**會延遲匯入 PySide6 的地方,維持頂層套件 Qt-free。 | | `main_window.py` | 407 | `QMainWindow`:選單列(File/Actions/View/…)、左側導覽面板 dock(`Ctrl+K` 搜尋、`Ctrl+B` 收合)、即時語言切換、字級預設、深色/淺色主題(`theme.py` 的 token,不再用 qt-material)。分頁分為 core/editing/detection/automation/system 五類。 | -| `main_widget.py` | 370 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | -| `tab_registry.py` | 125 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。不匯入 Qt。 | +| `main_widget.py` | 390 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/close/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | +| `tab_registry.py` | 183 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。`TabRegistry.open/close` 快取或釋放 factory 面板,保留 metadata 與既有表單;不匯入 Qt。 | | `navigation.py` | 200 | `NavigationPanel`:搜尋框 + 依分類的功能樹,列出每個已註冊分頁(開啟中的以粗體標示),只回報被選的鍵,開啟分頁仍由視窗負責。 | | `theme.py` | 178 | 設計 token(`ThemeTokens`:顏色、圓角、間距、字族)、深色與淺色兩組值、由 token 產生的樣式表與對應的 `QPalette`;不載入圖檔或字型檔。 | | `workspace.py` | 121 | 可捲動的分頁容器:保留 `widget/currentWidget/indexOf/setCurrentWidget` 的原始頁面身分、移除時保留頁面所有權,並直接繪製隨 palette 變色的向量關閉鈕。 | @@ -1081,7 +1081,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 100 | 28,704 | +| `gui/` | 100 | 28,782 | | `utils/mcp_server/` | 37 | 19,164 | | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,666 | @@ -1102,5 +1102,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,669 | -| **總計** | **1,074** | **161,828** | +| **總計** | **1,074** | **161,906** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 6c9dc2990..62cd0b251 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -216,6 +216,13 @@ The main window is now a ``QMainWindow`` with: Close any tab with its ✕ button; re-open it via *View → Tabs*. +Factory-built feature panels are deleted on the next Qt event-loop turn, releasing +their destruction-bound subscriptions and timers. Reopening creates a fresh panel, +so save edits first. View-menu hiding and ``hide_tab`` preserve the existing panel. +Embedders can use ``tab_registry.open(key)`` / ``close(key)`` or ``close_tab(key)``; +a panel that refuses its close event stays open. Legacy owner-built forms and +Remote Desktop retain their widgets pending lifecycle migration. + OCR (text on screen) ==================== diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index f0e7fe8e0..e903e2eaa 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -203,6 +203,11 @@ GUI 多語系 點擊分頁的 ✕ 即可關閉,之後可從 *View → Tabs* 恢復。 +factory 建立的功能面板會在下一次 Qt event loop 刪除,釋放綁定於銷毀事件的訂閱與計時器。 +重新開啟會建立新面板,請先儲存編輯內容。View 選單隱藏與 ``hide_tab`` 會保留原面板。 +嵌入端可用 ``tab_registry.open(key)`` / ``close(key)`` 或 ``close_tab(key)``; +面板拒絕 close event 時仍保持開啟。既有的擁有者表單與 Remote Desktop 在生命週期遷移前保留 widget。 + OCR 螢幕文字辨識 ================ diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index a904c9277..06c7f2cdb 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -348,3 +348,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 15 new cases cover all CLI seed/preview combinations, repeated and concurrent runs, cross-state values, nested calls and restoration after errors; a 128-test related regression run and the subsequent 15-case focused run pass; 166 flow-control, documentation and cross-project checks also pass. Three-platform mypy reports zero modules with errors; strict English and Traditional Chinese Sphinx builds pass. - **Files**: `cli.py`, `utils/llm/planner.py`, `utils/state_machine/engine.py`, variable-scope documentation, regression tests, three READMEs, Sphinx references, changelog and architecture maps. - **Open items**: native free-threaded concurrency verification remains in `Progress.md`. + +## U-20261018-07 · 2026-10-18 · Release factory-built GUI panels on explicit tab close · #done #gui #lifecycle + +- **What**: Qt-free `TabRegistry.open/close` caches widgets on open and retains feature metadata after close. The workspace's tab cross now uses explicit `close_tab`; accepted closes detach factory panels and schedule their destruction so existing cleanup hooks release subscriptions, timers and log handlers. A refused close keeps its panel open; unopened or unknown close requests build nothing. +- **Compatibility**: `show_tab`, `hide_tab`, navigation and listing keep their existing contracts, including reusing hidden panels and stable feature keys/order. Owner-built forms and Remote Desktop retain widgets while their controls and session ownership are migrated in F3. Closing another factory-built panel discards local UI state; reopening builds a new panel. +- **Validation**: 99 selected GUI tests pass, followed by all eight final lifecycle cases covering lazy open, refused close, Presence subscription teardown/recreation, HUD log detachment, hidden-panel release and retained legacy forms. 100 documentation, budget and cross-project checks pass; English/Traditional Chinese strict Sphinx builds pass. Three-platform mypy reports zero error modules; wheel and source distribution builds pass. +- **Files**: `gui/tab_registry.py`, `gui/main_widget.py`, lifecycle regression tests, three READMEs, Sphinx GUI guide, changelog and architecture maps. +- **Open items**: F1 startup deferral, legacy panel/session destruction and the broader F2–F4 work remain in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index ade038e97..a2ab9056d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-07 | 2026-10-18 | Release factory-built GUI panels on explicit tab close | #done #gui #lifecycle | [2026-10](2026-10.md) | | U-20261018-06 | 2026-10-18 | Isolate CLI previews, planner runs and state-machine variables | #done #executor #variables | [2026-10](2026-10.md) | | U-20261018-05 | 2026-10-18 | Report refused MCP window-state operations as tool errors | #done #mcp #window | [2026-10](2026-10.md) | | U-20261018-04 | 2026-10-18 | Recheck platform wheel gaps without lowering security floors | #snapshot #deps #platforms | [2026-10](2026-10.md) | @@ -369,7 +370,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 33 | +| [2026-10.md](2026-10.md) | 2026-10 | 34 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index 9770fdaf1..0a967f1fc 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -17,7 +17,7 @@ from je_auto_control.gui._script_tab import ScriptTabMixin from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper from je_auto_control.gui.tab_registry import ( - TAB_SPECS, MenuActions, TabEntry, WidgetFactory, lazy_factory, + TAB_SPECS, MenuActions, TabEntry, TabRegistry, WidgetFactory, lazy_factory, ) from je_auto_control.gui.workspace import ScrollableTabs from je_auto_control.utils.exception.exceptions import AutoControlException @@ -52,6 +52,7 @@ def __init__(self, parent=None): layout.setContentsMargins(0, 0, 0, 0) self._tab_entries: List[TabEntry] = [] + self.tab_registry = TabRegistry(self._tab_entries, on_close=self._detach_closed_tab) self.tabs = ScrollableTabs() self.tabs.setTabsClosable(True) @@ -177,10 +178,12 @@ def _add_tab( if isinstance(widget, QWidget): built = widget entry = TabEntry(key=key, title_key=title_key, factory=lambda: built, - category=category, default_visible=default_visible, actions=actions) + category=category, default_visible=default_visible, actions=actions, + retain_on_close=True) else: entry = TabEntry(key=key, title_key=title_key, factory=widget, category=category, default_visible=default_visible, actions=actions, + retain_on_close=key == "remote_desktop", on_build=lambda page: AutoControlGUIWidget._adopt_hidden_tab(self, page)) self._tab_entries.append(entry) if default_visible: @@ -283,8 +286,9 @@ def show_tab(self, key: str) -> None: if self._is_open(candidate): target_index += 1 title = language_wrapper.translate(entry.title_key, entry.title_key) - self.tabs.insertTab(target_index, entry.widget, title) - self.tabs.setCurrentWidget(entry.widget) + page = self.tab_registry.open(key) + self.tabs.insertTab(target_index, page, title) + self.tabs.setCurrentWidget(page) self.tabs_changed.emit() def activate_tab(self, key: str) -> bool: @@ -312,9 +316,25 @@ def _on_tab_close_requested(self, index: int) -> None: widget = self.tabs.widget(index) for entry in self._built_entries(): if entry.widget is widget: - self.hide_tab(entry.key) + self.close_tab(entry.key) return + def close_tab(self, key: str) -> None: + """Release a feature; keep legacy forms and Remote Desktop reusable. + + Unlike ``hide_tab``, closing a feature discards its local UI state and + schedules cleanup of subscriptions and timers. Reopening rebuilds it. + Remote Desktop retains live sessions until its lifecycle is migrated. + """ + self.tab_registry.close(key) + + def _detach_closed_tab(self, page: QWidget) -> None: + """Remove a closed page before its deferred Qt destruction.""" + index = self.tabs.indexOf(page) + if index != -1: + self.tabs.removeTab(index) + self.tabs_changed.emit() + def _translate(self, key: str) -> str: return language_wrapper.translate(key, key) diff --git a/je_auto_control/gui/tab_registry.py b/je_auto_control/gui/tab_registry.py index dd9633464..443145cfc 100644 --- a/je_auto_control/gui/tab_registry.py +++ b/je_auto_control/gui/tab_registry.py @@ -3,13 +3,22 @@ Every feature tab is described by a :class:`TabSpec` — key, title key, category and where its widget class lives — and nothing more is loaded until the tab is opened. :class:`TabEntry` holds one registered tab and builds the -widget on first access to ``widget``, so the window no longer imports and +widget on first access to ``widget``. :class:`TabRegistry` owns explicit +open/close operations; close schedules owned panels for deletion so their +destruction hooks release subscriptions and timers. The window no longer imports and constructs some fifty panels (with the timers and helper threads several of them start) before it can show the three it opens on. """ +from __future__ import annotations + from dataclasses import dataclass, field from importlib import import_module -from typing import Any, Callable, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple + +from je_auto_control.utils.exception.exceptions import AutoControlActionException + +if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget MenuActions = Tuple[Tuple[str, Callable[[], Any]], ...] WidgetFactory = Callable[[], Any] @@ -47,6 +56,7 @@ class TabEntry: default_visible: bool = False actions: MenuActions = () on_build: Optional[Callable[[Any], None]] = None + retain_on_close: bool = False _widget: Any = field(default=None, repr=False) @property @@ -63,6 +73,54 @@ def widget(self) -> Any: self.on_build(self._widget) return self._widget + def release(self) -> None: + """Drop an owned panel and schedule its Qt cleanup, retaining metadata.""" + if self._widget is not None and not self.retain_on_close: + page, self._widget = self._widget, None + page.deleteLater() + + +class TabRegistry: + """Open cached panels and release owned ones on explicit close. + + Metadata stays registered after close, so reopening creates the same + feature with a new widget. Legacy forms supplied as existing widgets use + ``retain_on_close``: their owner still exposes their controls to embedders. + This module imports no Qt until a factory requests it. Use on the GUI thread. + """ + + def __init__(self, entries: list[TabEntry], *, + on_close: Optional[Callable[[QWidget], None]] = None) -> None: + self._entries = entries + self._on_close = on_close + + def _find(self, key: str) -> Optional[TabEntry]: + return next((entry for entry in self._entries if entry.key == key), None) + + def open(self, key: str) -> QWidget: + """Build and return ``key`` without recreating an existing panel.""" + entry = self._find(key) + if entry is None: + raise AutoControlActionException(f"Unknown tab: {key}") + return entry.widget + + def close(self, key: str) -> None: + """Close a built panel and defer its deletion; respect a refused close. + + Missing/unopened keys do nothing. The workspace detaches the page via + ``on_close``. Qt releases subscriptions and child timers during deferred + deletion; callers must return to its event loop to complete cleanup. + """ + entry = self._find(key) + if entry is None or not entry.built: + return + page = entry.widget + if not page.close(): + return + if self._on_close is not None: + self._on_close(page) + entry.release() + _GUI = "je_auto_control.gui" diff --git a/test/unit_test/headless/test_gui_registry_close.py b/test/unit_test/headless/test_gui_registry_close.py new file mode 100644 index 000000000..9247240cd --- /dev/null +++ b/test/unit_test/headless/test_gui_registry_close.py @@ -0,0 +1,138 @@ +"""Explicit registry close releases feature resources without changing hide semantics.""" +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + +@pytest.fixture(scope="module") +def qapp(): + widgets = pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) + return widgets.QApplication.instance() or widgets.QApplication([]) + + +def _flush(qapp): + from PySide6.QtCore import QEvent + qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete.value) + + +def test_registry_open_is_lazy_and_close_recreates_the_same_key(qapp): + from PySide6.QtWidgets import QWidget + from je_auto_control.gui.tab_registry import TabEntry, TabRegistry + + built, closed = [], [] + + def factory(): + page = QWidget() + page.tab_key = "feature" + built.append(page) + return page + + entry = TabEntry("feature", "feature", factory) + registry = TabRegistry([entry], on_close=closed.append) + registry.close("feature") + assert not entry.built and built == [] + first = registry.open("feature") + assert registry.open("feature") is first + registry.close("feature") + assert not entry.built and closed == [first] + _flush(qapp) + second = registry.open("feature") + assert second is not first and second.tab_key == first.tab_key + registry.close("feature") + _flush(qapp) + + +def test_registry_respects_a_rejected_close(qapp): + from PySide6.QtWidgets import QWidget + from je_auto_control.gui.tab_registry import TabEntry, TabRegistry + + class UnsavedPage(QWidget): + def closeEvent(self, event): + event.ignore() + + closed = [] + entry = TabEntry("unsaved", "unsaved", UnsavedPage) + registry = TabRegistry([entry], on_close=closed.append) + page = registry.open("unsaved") + registry.close("unsaved") + assert entry.built and registry.open("unsaved") is page + assert closed == [] + page.deleteLater() + + +@pytest.fixture +def workspace(qapp, monkeypatch): + from PySide6.QtWidgets import QWidget + from je_auto_control.gui import main_widget + monkeypatch.setattr(main_widget, "TAB_SPECS", tuple( + spec for spec in main_widget.TAB_SPECS if spec.key in {"record", "presence", "live_hud", "remote_desktop"} + )) + monkeypatch.setattr(main_widget.AutoControlGUIWidget, "_build_remote_desktop_tab", staticmethod(QWidget)) + widget = main_widget.AutoControlGUIWidget() + yield widget + widget.deleteLater() + _flush(qapp) + + +def test_close_button_releases_presence_listener_and_reopen_resubscribes(workspace, qapp): + from je_auto_control.utils.remote_desktop.presence import default_presence_registry + registry = default_presence_registry() + baseline = len(registry._listeners) + workspace.show_tab("presence") + entry = workspace._find_entry("presence") + first = entry.widget + assert len(registry._listeners) == baseline + 1 + workspace._on_tab_close_requested(workspace.tabs.indexOf(first)) + assert not entry.built + assert not next(row for row in workspace.list_registered_tabs() if row["key"] == "presence")["visible"] + _flush(qapp) + assert len(registry._listeners) == baseline + workspace.show_tab("presence") + assert entry.widget is not first and len(registry._listeners) == baseline + 1 + + +def test_close_detaches_hud_log_tail(workspace, qapp): + from je_auto_control.utils.logging.logging_instance import autocontrol_logger + workspace.show_tab("live_hud") + entry = workspace._find_entry("live_hud") + tail = entry.widget._log_tail + entry.widget._start() + assert tail in autocontrol_logger.handlers + workspace.close_tab("live_hud") + _flush(qapp) + assert not entry.built and tail not in autocontrol_logger.handlers + + +def test_hide_still_reuses_a_panel_and_explicit_close_can_release_it(workspace, qapp): + workspace.show_tab("presence") + entry = workspace._find_entry("presence") + page = entry.widget + workspace.hide_tab("presence") + assert entry.built + workspace.show_tab("presence") + assert entry.widget is page + workspace.hide_tab("presence") + workspace.close_tab("presence") + assert not entry.built + _flush(qapp) + + +def test_unknown_key_does_not_build_any_registered_page(workspace): + from je_auto_control.utils.exception.exceptions import AutoControlException + before = [entry.built for entry in workspace._tab_entries] + with pytest.raises(AutoControlException, match="Unknown tab"): + workspace.tab_registry.open("missing") + workspace.tab_registry.close("missing") + assert [entry.built for entry in workspace._tab_entries] == before + + +@pytest.mark.parametrize("key", ["record", "remote_desktop"]) +def test_legacy_form_and_remote_session_owner_are_retained_on_close(workspace, key): + entry = workspace._find_entry(key) + first = entry.widget + workspace.close_tab(key) + assert entry.built and workspace.tabs.indexOf(first) == -1 + workspace.show_tab(key) + assert entry.widget is first From 7b997cb042bdc4cdbf493c75a7328fc80a044374 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 21:00:24 +0800 Subject: [PATCH 008/294] Present REST RBAC mode and preserve legacy gates --- CHANGELOG.md | 6 + Progress.md | 2 - README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 5 + architecture_explore.md | 22 +-- .../operations_layer/operations_layer_doc.rst | 11 ++ .../operations_layer/operations_layer_doc.rst | 8 + docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- .../gui/language_wrapper/english.py | 1 + .../gui/language_wrapper/japanese.py | 1 + .../language_wrapper/simplified_chinese.py | 1 + .../language_wrapper/traditional_chinese.py | 1 + je_auto_control/gui/rest_api_tab.py | 6 +- .../utils/rest_api/rest_registry.py | 6 +- je_auto_control/utils/rest_api/rest_server.py | 26 +++- .../headless/test_rest_auth_compatibility.py | 138 ++++++++++++++++++ 19 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 test/unit_test/headless/test_rest_auth_compatibility.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5d09486..894cdf227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- REST registry status adds `rbac_enabled` and suppresses the inactive shared + token in RBAC mode (`token: null`). The GUI explains user-token authentication + and disables its shared-token field. The Python server's `token` property stays + unchanged. Check-only custom gates work in shared-token mode; an identityless + success cannot bypass a server's configured RBAC policy. + - The GUI tab close button now releases factory-built feature panels and their destruction-bound resources; reopening creates a fresh panel. View-menu hiding and `hide_tab` retain state. Explicit `TabRegistry.open/close` and `close_tab` diff --git a/Progress.md b/Progress.md index 5a9187028..366611ca8 100644 --- a/Progress.md +++ b/Progress.md @@ -210,9 +210,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart 強制簽章經 socket/REST/MCP 的端到端沒有跑過,覆蓋的是共用的 `read_executable_action_json` 與指令本身。 - **RBAC**(`JE_AUTOCONTROL_RBAC_USERS`,預設關閉): - 使用者只能用 Python 管(`UserStore.add_user/set_role/rotate_token/remove_user`),沒有 `AC_*`、CLI 或 GUI;`Capability.MANAGE_USERS` 因此沒有任何路由或工具對應。 - - `gui/rest_api_tab.py` 仍顯示共用 token,RBAC 開啟後那個 token 會被拒絕。 - viewer 拿得到所有標成唯讀的工具,包括 `ac_sql_query`、`ac_load_dotenv`、`ac_get_clipboard`、`ac_jwt_encode`,沒有重新分類。 - - 只實作 `check()` 的自製 REST gate 現在會 `AttributeError`(要有 `authenticate()`)。 - 沒對真的 client(Claude Desktop、VS Code、內建 dashboard)試過;SSE、無狀態與 `subscriptions/listen` 路徑、TLS 加 RBAC 沒有測試。 - **MCP 路徑根目錄**(`JE_AUTOCONTROL_MCP_PATH_ROOTS`,預設關閉)管不到的參數:執行動作清單的工具、 有時才是路徑的參數(`ac_open_path`/`ac_plan_open`/`ac_file_association`/`ac_act_in_view` 的 `target`,`ac_launch_process`/`ac_shell` 的 `argv`/`command`)、 diff --git a/README.md b/README.md index a1b81f89b..fb03d9298 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,8 @@ The MCP window-state tools `ac_window_minimize`, `ac_window_maximize` and `ac_wi **Opt-in hardening.** Each of these is off until configured, and a server without them behaves as before. `JE_AUTOCONTROL_RBAC_USERS=` makes the REST API and the MCP HTTP transport resolve the bearer token to a user and authorise each route, tool and privileged `AC_*` command by role (viewer / operator / admin); the shared token is then refused. `JE_AUTOCONTROL_MCP_PATH_ROOTS` (directories separated by the OS path separator) confines every MCP tool argument that is a file path to those directories, `JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` adds the client's `roots/list`, and `JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` limits which `env://` names `ac_resolve_ref` may read. `JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` makes an endpoint verify Ed25519-signed action files without being able to sign them (`create_signing_keypair`; the private key stays on the signing machine). A remote-desktop viewer writes files pushed by a host only below `~/Downloads/AutoControl` (`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`); that one is on by default. +The REST GUI shows RBAC mode and disables the shared-token field while a user store is active. `rest_api_registry.status()` includes `rbac_enabled` and returns `token: null` in that mode; use an individual user token. Custom REST gates that only implement `check()` remain supported in shared-token mode; RBAC requires an authenticated user context and refuses an identityless success. + Delayed interval/cron jobs, triggers, hotkeys, watchdog rules, webhooks, e-mail triggers and observer callbacks retain their registrant’s identity. Store-backed users and roles are checked again on dispatch; deleted users, unavailable stores and viewers cannot execute deferred work. Starting a daemon does not transfer its registrations to the starter. Ownership metadata retains no RBAC bearer token; explicit Python `AuthorizationContext` values without `store_path` keep their captured role, and work registered outside RBAC clears worker identity. Observer/watchdog callbacks get fresh variable scopes, and failed watchdog actions do not count as dismissed popups. Nested callbacks retain the caller’s failure count; a webhook with a revoked owner returns HTTP 403 and `fired: false`. ### How the remote-desktop wire protocol works diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 3c3d96491..bc7c333ef 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -287,6 +287,8 @@ MCP 窗口状态工具 `ac_window_minimize`、`ac_window_maximize` 与 `ac_windo **可选的加固设置。** 下列各项在设置之前都是关闭的,没设置的服务器行为与先前相同。`JE_AUTOCONTROL_RBAC_USERS=<用户文件>` 让 REST API 与 MCP HTTP 传输把 bearer token 对应到用户,并按角色(viewer/operator/admin)授权每一个路由、工具与特权 `AC_*` 命令;此时共用 token 会被拒绝。`JE_AUTOCONTROL_MCP_PATH_ROOTS`(以操作系统的路径分隔符分开的目录)把每一个属于文件路径的 MCP 工具参数限制在这些目录里,`JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` 再加上 client 的 `roots/list`,`JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` 限制 `ac_resolve_ref` 可以读哪些 `env://` 名称。`JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` 让端点只能验证 Ed25519 签名的动作文件而不能签名(`create_signing_keypair`;私钥留在签名的机器上)。远程桌面的 viewer 只把 host 推来的文件写在 `~/Downloads/AutoControl` 下面(`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`);这一项默认就是开的。 +REST GUI 在用户存储文件启用时显示 RBAC 模式并禁用共用 token 字段。`rest_api_registry.status()` 提供 `rbac_enabled`,该模式的 `token` 为 `null`;请使用个别用户 token。只实现 `check()` 的自制 REST gate 仍可用于共用 token 模式;RBAC 要求已验证的用户 context,会拒绝没有身份的成功结果。 + 间隔/cron 调度、触发器、热键、watchdog 规则、webhook、e-mail 触发器与 observer 回调会保留注册者身份。每次触发重新检查来源用户文件与角色;已删除的用户、无法使用的来源文件与 viewer 不能执行后台工作。启动 daemon 不会改变已有工作的所有者。所有者信息不保存 RBAC bearer token;未带 `store_path` 的显式 Python `AuthorizationContext` 保留角色快照,未在 RBAC 下注册的工作会清除 worker 原有身份。observer/watchdog 回调使用独立变量作用域,watchdog 动作失败不会算成成功消除弹窗。嵌套回调会保留调用端的失败计数;webhook 所有者被撤销权限时响应 HTTP 403 与 `fired: false`。 ### 远程桌面的线路协议 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 6b1e63875..3b33e4c89 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -286,6 +286,8 @@ MCP 視窗狀態工具 `ac_window_minimize`、`ac_window_maximize` 與 `ac_windo **選用的強化設定。** 下列各項在設定之前都是關閉的,沒設定的伺服器行為與先前相同。`JE_AUTOCONTROL_RBAC_USERS=<使用者檔>` 讓 REST API 與 MCP HTTP 傳輸把 bearer token 對到使用者,並依角色(viewer/operator/admin)授權每一個路由、工具與特權 `AC_*` 命令;此時共用 token 會被拒絕。`JE_AUTOCONTROL_MCP_PATH_ROOTS`(以作業系統的路徑分隔字元分開的目錄)把每一個屬於檔案路徑的 MCP 工具參數限制在這些目錄裡,`JE_AUTOCONTROL_MCP_PATH_ROOTS_FROM_CLIENT=1` 再加上 client 的 `roots/list`,`JE_AUTOCONTROL_MCP_ENV_REF_ALLOW` 限制 `ac_resolve_ref` 可以讀哪些 `env://` 名稱。`JE_AUTOCONTROL_ACTION_SIGNING_PUBLIC_KEY` 讓端點只能驗證 Ed25519 簽章的動作檔而不能簽章(`create_signing_keypair`;私鑰留在簽章的機器上)。遠端桌面的 viewer 只把 host 推來的檔案寫在 `~/Downloads/AutoControl` 底下(`JE_AUTOCONTROL_REMOTE_DOWNLOAD_DIR`);這一項預設就是開的。 +REST GUI 在使用者存放檔啟用時顯示 RBAC 模式並停用共用 token 欄位。`rest_api_registry.status()` 提供 `rbac_enabled`,該模式的 `token` 為 `null`;請使用個別使用者 token。只實作 `check()` 的自製 REST gate 仍可用於共用 token 模式;RBAC 要求已驗證的使用者 context,會拒絕沒有身分的成功結果。 + 間隔/cron 排程、觸發器、熱鍵、watchdog 規則、webhook、e-mail 觸發器與 observer 回呼會保留註冊者身分。每次觸發重新檢查來源使用者檔與角色;已刪除的使用者、無法使用的來源檔與 viewer 不能執行背景工作。啟動 daemon 不會改變既有工作的擁有者。擁有者資訊不保存 RBAC bearer token;未帶 `store_path` 的明確 Python `AuthorizationContext` 保留角色快照,未在 RBAC 下註冊的工作會清除 worker 原有身分。observer/watchdog 回呼使用獨立變數範圍,watchdog 動作失敗不會算成成功排除彈窗。巢狀回呼會保留呼叫端的失敗計數;webhook 擁有者被撤銷權限時回應 HTTP 403 與 `fired: false`。 ### 遠端桌面的線路協定 diff --git a/architecture.md b/architecture.md index 45be86f5f..fca495514 100644 --- a/architecture.md +++ b/architecture.md @@ -85,6 +85,11 @@ AC_web_run / AC_web_run_actions → utils/webrunner_bridge/bridge.py (event_dict["WR_*"] on a WebRunner without execute_one); a failure → WebRunnerBridgeError ``` +REST gate adaptation preserves check-only custom gates under shared-token auth; +store-backed servers require an authenticated user context even from replacement +gates. Registry status exposes `rbac_enabled` and omits the inactive shared token +so the GUI presents the authentication mode that the server actually accepts. + Delayed work captures registration authority through `utils/rbac/deferred.py`. Schedulers, triggers, hotkeys, watchdogs, webhooks, e-mail watchers and observers bind that owner at dispatch, independently of the worker's ambient context. diff --git a/architecture_explore.md b/architecture_explore.md index 055e9fdeb..cc40baa0a 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,080 | -| 程式碼總行數 | 161,971 | +| 程式碼總行數 | 161,997 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -527,7 +527,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.11 伺服器、網路協定與外部整合 -> 24 個套件、約 7,101 行。 +> 24 個套件、約 7,121 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -549,7 +549,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/otp/` | 37 | TOTP 一次性密碼產生(自動化 2FA 登入) | | `utils/outbox/` | 107 | 交易式 outbox,保證至少一次的事件投遞 | | `utils/pytest_plugin/` | 283 | pytest 外掛 + BDD step library(`pytest11` entry point) | -| `utils/rest_api/` | 2,039 | 純標準庫 REST 前端:路由、Bearer 驗證、限流、Prometheus 指標、OpenAPI 3.1 產生 | +| `utils/rest_api/` | 2,059 | 純標準庫 REST 前端:路由、Bearer 驗證、限流、Prometheus 指標、OpenAPI 3.1 產生 | | `utils/socket_server/` | 163 | 執行 action JSON 的執行緒式 TCP 指令伺服器(預設綁 127.0.0.1) | | `utils/sse_client/` | 142 | Server-Sent Events 用戶端解析 | | `utils/tls_acme/` | 473 | TLS 自動化:HTTP-01 挑戰伺服器、金鑰/CSR、自動續期 | @@ -820,16 +820,16 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `usbip/libusb_backend.py` | 224 | 以 PyUSB/libusb 執行 URB 的正式後端。 | | `usbip/backend.py` | 87 | 可插拔 URB 執行後端。 | -#### `utils/rest_api/`(2,039 行) +#### `utils/rest_api/`(2,059 行) | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `rest_server.py` | 634 | HTTP 前端主體。 | +| `rest_server.py` | 650 | HTTP 前端主體;相容 check-only gate,RBAC 必須帶使用者身分。 | | `rest_handlers.py` | 528 | 端點實作。 | | `rest_openapi.py` | 443 | 走訪路由表產生 OpenAPI 3.1 規格。 | | `rest_auth.py` | 202 | Bearer token 驗證 + 逐 client 限流閘門。 | | `rest_metrics.py` | 75 | Prometheus 曝露端點。 | -| `rest_registry.py` | 77 | 保存執行中 REST 伺服器的行程級單例。 | +| `rest_registry.py` | 81 | 保存執行中 REST 伺服器的行程級單例,狀態包含 RBAC 模式並隱去停用的共用 token。 | | `__main__.py` | 66 | `python -m je_auto_control.utils.rest_api` 進入點。 | #### 其他多檔子套件 @@ -899,7 +899,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `_screen_geometry.py` | 55 | Qt 邏輯座標與截圖用的原生像素互轉:`native_region()`、`screen_at_native()`、`logical_point()`(每個螢幕的左上角在兩者相同,螢幕內依 device pixel ratio 縮放)。區域選取與主機端標註覆蓋層都用它。 | | `_daemon_thread.py` | 79 | `DaemonThread`:`QThread` 的替代品,保留遠端桌面 worker 用到的介面(`start`/`run`/`isRunning`/`wait`/`requestInterruption`/`started`/`finished`),但 `run()` 跑在 daemon `threading.Thread` 上,刪除物件或程式結束都不會銷毀執行中的執行緒。 | | `_worker_thread.py` | 216 | `start_worker()`:在 daemon `threading.Thread` 上執行 `QObject` worker 的 `run()`(沒有 `QThread` 可被銷毀),並經由分頁擁有的中繼物件回報結果(回呼一律在 GUI 執行緒;worker 沒處理的例外也送到 `on_fail`);worker 留在模組登錄表直到 GUI 執行緒看到它結束,回傳 `WorkerHandle`(`isRunning()`);程式結束時先呼叫 worker 的 `request_stop()`,最多等 10 秒,仍在跑的隨行程結束。 | -| `language_wrapper/` | 5,071 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | +| `language_wrapper/` | 5,075 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | | `selector/` | 216 | 拖曳選取螢幕區域的半透明全螢幕覆蓋層與樣板裁切工具(互動式,但都有對應的程式化 API)。 | > **分頁指令一律走 Actions 選單**:分頁本身只放輸入、表格與結果檢視,指令由視窗層選單暴露。 @@ -949,7 +949,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | trace_replay | `trace_replay_tab.py` | 187 | 拖曳捲動時光回溯錄製內容。 | | remote_desktop | `remote_desktop/`(17 檔) | 6,240 | 見下。 | | presence | `presence_tab.py` | 152 | 多檢視者在場名單。 | -| rest_api | `rest_api_tab.py` | 198 | 啟停 HTTP 前端並顯示 URL 與 token。 | +| rest_api | `rest_api_tab.py` | 198 | 啟停 HTTP 前端並顯示 URL、實際驗證模式與可用 token。 | | admin_console | `admin_console_tab.py` | 313 | 管理多個遠端 AutoControl REST 端點。 | | audit_log | `audit_log_tab.py` | 192 | 瀏覽並驗證防竄改雜湊鏈。 | | inspector | `inspector_tab.py` | 121 | WebRTC 檢測器:即時摘要與近期統計取樣。 | @@ -1081,7 +1081,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 100 | 28,782 | +| `gui/` | 100 | 28,788 | | `utils/mcp_server/` | 37 | 19,164 | | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,666 | @@ -1090,7 +1090,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | -| `utils/rest_api/` | 8 | 2,039 | +| `utils/rest_api/` | 8 | 2,059 | | `utils/agent/` | 9 | 2,142 | | `linux_with_x11/` | 19 | 1,281 | | `linux_wayland/` | 17 | 2,921 | @@ -1102,5 +1102,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,669 | -| **總計** | **1,074** | **161,906** | +| **總計** | **1,074** | **161,932** | diff --git a/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst b/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst index 8095392d8..7f6339c9f 100644 --- a/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst +++ b/docs/source/Eng/doc/operations_layer/operations_layer_doc.rst @@ -137,6 +137,17 @@ CLI:: Roles (opt-in RBAC) ------------------- +The REST GUI labels RBAC mode and disables its shared-token input. Registry +status includes ``rbac_enabled`` and reports ``token: null`` while a user store is +configured; use a token belonging to an individual user. Stopping the server +restores the shared-token controls. The Python server's ``token`` property still +holds its configured shared token, which RBAC does not accept. + +Custom REST gates implementing only ``check(client_ip=..., header_value=...)`` +retain their verdict and rate-limit behavior in shared-token mode. When the +server has a user store, a successful gate result must include an authenticated +``AuthorizationContext``; an identityless success returns HTTP 401. + By default every caller holds the one shared token and may use every endpoint. Point the server at a user store and each caller gets a token of their own and a role instead: diff --git a/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst b/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst index b59da8b42..d5f3afd0a 100644 --- a/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst +++ b/docs/source/Zh/doc/operations_layer/operations_layer_doc.rst @@ -128,6 +128,14 @@ CLI:: 角色(選用的 RBAC) -------------------- +REST GUI 會標示 RBAC 模式並停用共用 token 輸入。Registry 狀態提供 ``rbac_enabled``, +使用者存放檔啟用時回報 ``token: null``;請改用個別使用者的 token。停止伺服器會恢復共用 +token 控制項。Python 伺服器的 ``token`` 屬性仍保留設定的共用 token,RBAC 不接受它。 + +只實作 ``check(client_ip=..., header_value=...)`` 的自製 REST gate 在共用 token 模式下 +保留原本的判定與限流行為。伺服器設定使用者存放檔時,成功結果必須包含已驗證的 +``AuthorizationContext``;沒有身分的成功判定會回覆 HTTP 401。 + 預設情況下所有呼叫端共用同一個 token,可以使用所有端點。把伺服器指向一個使用者 存放檔之後,每個呼叫端改為擁有自己的 token 與角色: diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 06c7f2cdb..8f24fbdcd 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -356,3 +356,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 99 selected GUI tests pass, followed by all eight final lifecycle cases covering lazy open, refused close, Presence subscription teardown/recreation, HUD log detachment, hidden-panel release and retained legacy forms. 100 documentation, budget and cross-project checks pass; English/Traditional Chinese strict Sphinx builds pass. Three-platform mypy reports zero error modules; wheel and source distribution builds pass. - **Files**: `gui/tab_registry.py`, `gui/main_widget.py`, lifecycle regression tests, three READMEs, Sphinx GUI guide, changelog and architecture maps. - **Open items**: F1 startup deferral, legacy panel/session destruction and the broader F2–F4 work remain in `Progress.md`. + +## U-20261018-08 · 2026-10-18 · Present REST RBAC mode and preserve legacy gate compatibility · #done #rest #rbac + +- **What**: REST registry status now includes `rbac_enabled` and suppresses the inactive shared token when a user store is configured. The GUI explains user-token authentication in all four languages, disables the shared-token field and restores it after stopping RBAC. The Python server's existing `token` property remains unchanged. +- **Compatibility/security**: custom gates implementing only `check()` retain their verdicts and rate limiting under shared-token authentication. Store-backed servers require an authenticated user context independently of the gate implementation; an identityless success returns HTTP 401, including from a replacement gate with `authenticate()`. +- **Validation**: 170 REST/RBAC regression tests pass; 14 final focused/thread-safety cases pass, covering real local HTTP, explicit/environment stores, shared tokens, mode transitions and GUI copy behavior. 75 additional endpoint, schema, metrics and documentation/budget checks pass. Three-platform mypy reports zero error modules; English and Traditional Chinese strict Sphinx builds pass. +- **Files**: REST server/registry, GUI REST panel and four language catalogs, regression tests, three READMEs, Sphinx roles guide, changelog and architecture maps. +- **Open items**: user management surfaces, sensitive viewer tool classification and real-client/TLS RBAC acceptance remain in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index a2ab9056d..4e7d821e2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-08 | 2026-10-18 | Present REST RBAC mode and preserve legacy gate compatibility | #done #rest #rbac | [2026-10](2026-10.md) | | U-20261018-07 | 2026-10-18 | Release factory-built GUI panels on explicit tab close | #done #gui #lifecycle | [2026-10](2026-10.md) | | U-20261018-06 | 2026-10-18 | Isolate CLI previews, planner runs and state-machine variables | #done #executor #variables | [2026-10](2026-10.md) | | U-20261018-05 | 2026-10-18 | Report refused MCP window-state operations as tool errors | #done #mcp #window | [2026-10](2026-10.md) | @@ -370,7 +371,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 34 | +| [2026-10.md](2026-10.md) | 2026-10 | 35 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index d376aadae..cb3cdd285 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -342,6 +342,7 @@ "rest_url": "URL:", "rest_active_token": "Bearer token:", "rest_running": "REST API is running.", + "rest_running_rbac": "REST API is running with RBAC. Use a user token; shared tokens are disabled.", "rest_stopped": "REST API is stopped.", "rest_config_export": "Export config", "rest_config_import": "Import config", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 33cf567d4..4dee87d8f 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -231,6 +231,7 @@ "rest_url": "URL:", "rest_active_token": "ベアラートークン:", "rest_running": "REST API は稼働中です。", + "rest_running_rbac": "REST API は RBAC で稼働中です。ユーザートークンを使用してください。共有トークンは無効です。", "rest_stopped": "REST API は停止しています。", "rest_config_export": "設定をエクスポート", "rest_config_import": "設定をインポート", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index fcb15748f..ab8b422ba 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -218,6 +218,7 @@ "rest_url": "URL:", "rest_active_token": "Bearer 令牌:", "rest_running": "REST API 运行中。", + "rest_running_rbac": "REST API 以 RBAC 运行中。请使用个别用户令牌;共用令牌已禁用。", "rest_stopped": "REST API 已停止。", "rest_config_export": "导出配置", "rest_config_import": "导入配置", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index 0c3878ede..dfe41f4aa 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -219,6 +219,7 @@ "rest_url": "URL:", "rest_active_token": "Bearer 權杖:", "rest_running": "REST API 執行中。", + "rest_running_rbac": "REST API 以 RBAC 執行中。請使用個別使用者權杖;共用權杖已停用。", "rest_stopped": "REST API 已停止。", "rest_config_export": "匯出設定", "rest_config_import": "匯入設定", diff --git a/je_auto_control/gui/rest_api_tab.py b/je_auto_control/gui/rest_api_tab.py index cd1274418..28b8b59b7 100644 --- a/je_auto_control/gui/rest_api_tab.py +++ b/je_auto_control/gui/rest_api_tab.py @@ -184,10 +184,12 @@ def _on_copy_token(self) -> None: def _refresh_status(self) -> None: status = rest_api_registry.status() + rbac_enabled = bool(status.get("rbac_enabled", False)) + self._token_input.setEnabled(not rbac_enabled) if status["running"]: self._url_value.setText(status["url"]) - self._token_value.setText(status["token"]) - self._status_label.setText(_t("rest_running")) + self._token_value.setText("-" if rbac_enabled else status["token"]) + self._status_label.setText(_t("rest_running_rbac" if rbac_enabled else "rest_running")) else: self._url_value.setText("-") self._token_value.setText("-") diff --git a/je_auto_control/utils/rest_api/rest_registry.py b/je_auto_control/utils/rest_api/rest_registry.py index e466ef5e3..e629701b1 100644 --- a/je_auto_control/utils/rest_api/rest_registry.py +++ b/je_auto_control/utils/rest_api/rest_registry.py @@ -57,17 +57,21 @@ def stop(self, timeout: float = 2.0) -> Dict[str, Any]: return self.status() def status(self) -> Dict[str, Any]: + """Report the active auth mode; expose shared credentials only when usable.""" with self._lock: server = self._server if server is None: return { "running": False, "host": None, "port": 0, "token": None, "url": None, # nosec B105 # reason: dict key, value None means server stopped + "rbac_enabled": False, } host, port = server.address + rbac_enabled = server.user_store is not None return { "running": server.is_running, "host": host, "port": int(port), - "token": server.token, "url": server.base_url, + "token": None if rbac_enabled else server.token, "url": server.base_url, + "rbac_enabled": rbac_enabled, } diff --git a/je_auto_control/utils/rest_api/rest_server.py b/je_auto_control/utils/rest_api/rest_server.py index af3f2268b..0e07d30a8 100644 --- a/je_auto_control/utils/rest_api/rest_server.py +++ b/je_auto_control/utils/rest_api/rest_server.py @@ -27,7 +27,7 @@ ) from je_auto_control.utils.rbac.policy import capability_for_route, denied_command_in from je_auto_control.utils.rbac.users import UserStore -from je_auto_control.utils.rest_api.rest_auth import RestAuthGate, generate_token +from je_auto_control.utils.rest_api.rest_auth import AuthResult, RestAuthGate, generate_token from je_auto_control.utils.rest_api.rest_handlers import ( HandlerResult, RouteContext, handle_audit_list, handle_audit_verify, @@ -227,10 +227,7 @@ def _admit(self, method: str, path: str, client_ip: str, body: Any, The caller is ``None`` under the shared token: there is no user to hold a role, so there is nothing to authorise. """ - result = self._gate().authenticate( - client_ip=client_ip, - header_value=self.headers.get("Authorization"), - ) + result = self._authenticate(client_ip) if result.verdict != "ok": if result.verdict == "unauthorized": self._metrics().record_failed_auth() @@ -284,6 +281,23 @@ def _forbid(self, method: str, path: str, client_ip: str, def _gate(self) -> RestAuthGate: return self.server.auth_gate # type: ignore[attr-defined] + def _authenticate(self, client_ip: str) -> AuthResult: + """Adapt check-only gates, requiring identity when the server uses RBAC.""" + gate = self._gate() + authenticate = getattr(gate, "authenticate", None) + header = self.headers.get("Authorization") + result: AuthResult + if callable(authenticate): + result = authenticate(client_ip=client_ip, header_value=header) + else: + result = AuthResult(gate.check(client_ip=client_ip, header_value=header)) + # This flag belongs to the server configuration, independently of a + # replacement gate. An identityless "ok" must never disable RBAC. + if (getattr(self.server, "rbac_enabled", False) + and result.verdict == "ok" and result.context is None): + return AuthResult("unauthorized") + return result + def _metrics(self) -> RestMetrics: return self.server.metrics # type: ignore[attr-defined] @@ -511,6 +525,7 @@ def address(self) -> Tuple[str, int]: @property def token(self) -> str: + """The configured shared token, inactive when ``user_store`` is set.""" return self._token @property @@ -536,6 +551,7 @@ def start(self) -> None: return server = ThreadingHTTPServer(self._address, _RestRequestHandler) server.auth_gate = self._auth # type: ignore[attr-defined] + setattr(server, "rbac_enabled", self._users is not None) server.audit_log = self._audit_log # type: ignore[attr-defined] server.metrics = self._metrics # type: ignore[attr-defined] # `server_address` is typed for every address family a socketserver diff --git a/test/unit_test/headless/test_rest_auth_compatibility.py b/test/unit_test/headless/test_rest_auth_compatibility.py new file mode 100644 index 000000000..0a0a2ee72 --- /dev/null +++ b/test/unit_test/headless/test_rest_auth_compatibility.py @@ -0,0 +1,138 @@ +"""Legacy REST gates remain usable without bypassing store-backed identity.""" +import http.client +import json +import os +from types import SimpleNamespace + +import pytest + +from je_auto_control.utils.rbac.authorization import USERS_ENV +from je_auto_control.utils.rbac.users import UserStore +from je_auto_control.utils.rest_api.rest_auth import AuthResult +from je_auto_control.utils.rest_api.rest_registry import _RestApiRegistry +from je_auto_control.utils.rest_api.rest_server import RestApiServer + + +@pytest.fixture(autouse=True) +def no_ambient_store(monkeypatch): + monkeypatch.delenv(USERS_ENV, raising=False) + + +def _get(server, path="/commands"): + conn = http.client.HTTPConnection(*server.address, timeout=5) + try: + conn.request("GET", path, headers={"Authorization": "Bearer legacy"}) + reply = conn.getresponse() + return reply.status, json.loads(reply.read()) + finally: + conn.close() + + +class LegacyGate: + def __init__(self, verdict): + self.verdict = verdict + self.calls = [] + + def check(self, **kwargs): + self.calls.append(kwargs) + return self.verdict + + +@pytest.mark.parametrize("verdict,expected", [ + ("ok", 200), ("unauthorized", 401), ("rate_limited", 429), ("locked_out", 429), +]) +def test_check_only_gate_works_with_shared_auth(verdict, expected): + server = RestApiServer(port=0, enable_audit=False) + gate = LegacyGate(verdict) + server._auth = gate + server.start() + try: + status, _ = _get(server) + assert status == expected + assert gate.calls == [{"client_ip": "127.0.0.1", "header_value": "Bearer legacy"}] + finally: + server.stop() + + +@pytest.mark.parametrize("modern", [False, True]) +def test_identityless_custom_gate_cannot_bypass_rbac(tmp_path, modern): + users = UserStore(tmp_path / "users.json") + users.add_user(user_id="viewer", display_name="Viewer", role="viewer") + server = RestApiServer(port=0, enable_audit=False, user_store=users) + server._auth = (SimpleNamespace(authenticate=lambda **_kwargs: AuthResult("ok")) + if modern else LegacyGate("ok")) + server.start() + try: + assert _get(server)[0] == 401 + finally: + server.stop() + + +@pytest.mark.parametrize("rbac", [False, True]) +def test_registry_exposes_auth_mode_and_only_a_usable_shared_token(rbac, tmp_path): + registry = _RestApiRegistry() + assert registry.status()["rbac_enabled"] is False + users = UserStore(tmp_path / "users.json") if rbac else None + status = registry.start(port=0, token="shared", enable_audit=False, user_store=users) + try: + assert status["rbac_enabled"] is rbac + assert status["token"] == (None if rbac else "shared") + assert registry.server.token == "shared" # preserve the Python server property + finally: + registry.stop() + assert registry.status()["rbac_enabled"] is False + + +def test_registry_reports_environment_configured_rbac(tmp_path, monkeypatch): + monkeypatch.setenv(USERS_ENV, str(tmp_path / "users.json")) + registry = _RestApiRegistry() + try: + status = registry.start(port=0, enable_audit=False) + assert status["rbac_enabled"] is True and status["token"] is None + finally: + registry.stop() + + +@pytest.fixture(scope="module") +def qapp(): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + widgets = pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) + return widgets.QApplication.instance() or widgets.QApplication([]) + + +@pytest.mark.parametrize("rbac", [False, True]) +def test_gui_copies_shared_token_only_when_it_can_be_used(qapp, monkeypatch, rbac): + from je_auto_control.gui import rest_api_tab + copied = [] + monkeypatch.setattr(rest_api_tab.rest_api_registry, "status", lambda: { + "running": True, "url": "http://127.0.0.1:9939", "token": None if rbac else "shared", + "rbac_enabled": rbac, + }) + monkeypatch.setattr(rest_api_tab.QGuiApplication, "clipboard", lambda: SimpleNamespace(setText=copied.append)) + tab = rest_api_tab.RestApiTab() + try: + assert tab._token_input.isEnabled() is not rbac + assert tab._token_value.text() == ("-" if rbac else "shared") + tab._on_copy_token() + assert copied == ([] if rbac else ["shared"]) + if rbac: + assert "RBAC" in tab._status_label.text() + finally: + tab.deleteLater() + + +def test_gui_restores_shared_token_controls_after_rbac_stops(qapp, monkeypatch): + from je_auto_control.gui import rest_api_tab + status = {"running": True, "url": "http://127.0.0.1:9939", "token": None, "rbac_enabled": True} + monkeypatch.setattr(rest_api_tab.rest_api_registry, "status", lambda: status) + tab = rest_api_tab.RestApiTab() + try: + assert not tab._token_input.isEnabled() + status.update(running=False, rbac_enabled=False) + tab._refresh_status() + assert tab._token_input.isEnabled() and tab._token_value.text() == "-" + status.update(running=True, token="new-shared") + tab._refresh_status() + assert tab._token_value.text() == "new-shared" + finally: + tab.deleteLater() From 9fa99a827324d504710fa46e33c8ebe5d14a3fd0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 21:09:41 +0800 Subject: [PATCH 009/294] Separate sensitive MCP reads from viewer observation --- CHANGELOG.md | 8 ++ Progress.md | 2 +- README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 3 + architecture_explore.md | 10 +- .../Eng/doc/mcp_server/mcp_server_doc.rst | 14 ++- .../Zh/doc/mcp_server/mcp_server_doc.rst | 10 +- docs/updates/2026-10.md | 8 ++ docs/updates/README.md | 3 +- .../utils/mcp_server/tools/_factories.py | 6 +- je_auto_control/utils/rbac/policy.py | 65 +++++++++- .../test_rbac_sensitive_read_tools.py | 119 ++++++++++++++++++ .../headless/test_rbac_server_wiring.py | 5 +- 15 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 test/unit_test/headless/test_rbac_sensitive_read_tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 894cdf227..ed0ffd8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- RBAC viewer access now excludes named sensitive-read tools even when their + `readOnlyHint` remains true. Clipboard/history, local data/document/SQL readers, + dotenv/reference resolution, credential generators, persistent-store readers, + outbound checks, microphone capture, VLM requests and assertion containers + require operator/admin; tool listing and invocation use the same capability. + Unconfigured RBAC retains existing access. State-changing `ac_realize_item`, + `ac_idempotency_begin` and `ac_rate_limit` are excluded from read-only mode. + - REST registry status adds `rbac_enabled` and suppresses the inactive shared token in RBAC mode (`token: null`). The GUI explains user-token authentication and disables its shared-token field. The Python server's `token` property stays diff --git a/Progress.md b/Progress.md index 366611ca8..fbb78c47d 100644 --- a/Progress.md +++ b/Progress.md @@ -210,7 +210,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart 強制簽章經 socket/REST/MCP 的端到端沒有跑過,覆蓋的是共用的 `read_executable_action_json` 與指令本身。 - **RBAC**(`JE_AUTOCONTROL_RBAC_USERS`,預設關閉): - 使用者只能用 Python 管(`UserStore.add_user/set_role/rotate_token/remove_user`),沒有 `AC_*`、CLI 或 GUI;`Capability.MANAGE_USERS` 因此沒有任何路由或工具對應。 - - viewer 拿得到所有標成唯讀的工具,包括 `ac_sql_query`、`ac_load_dotenv`、`ac_get_clipboard`、`ac_jwt_encode`,沒有重新分類。 + - viewer 的其餘影像檔案型、mobile 與外掛唯讀工具仍需全面盤點 capability;自製工具仍依提示採預設分類。 - 沒對真的 client(Claude Desktop、VS Code、內建 dashboard)試過;SSE、無狀態與 `subscriptions/listen` 路徑、TLS 加 RBAC 沒有測試。 - **MCP 路徑根目錄**(`JE_AUTOCONTROL_MCP_PATH_ROOTS`,預設關閉)管不到的參數:執行動作清單的工具、 有時才是路徑的參數(`ac_open_path`/`ac_plan_open`/`ac_file_association`/`ac_act_in_view` 的 `target`,`ac_launch_process`/`ac_shell` 的 `argv`/`command`)、 diff --git a/README.md b/README.md index fb03d9298..0f13598f3 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ The MCP window-state tools `ac_window_minimize`, `ac_window_maximize` and `ac_wi The REST GUI shows RBAC mode and disables the shared-token field while a user store is active. `rest_api_registry.status()` includes `rbac_enabled` and returns `token: null` in that mode; use an individual user token. Custom REST gates that only implement `check()` remain supported in shared-token mode; RBAC requires an authenticated user context and refuses an identityless success. +RBAC viewers do not receive the named sensitive-read tools, even when their MCP `readOnlyHint` is true: clipboard/history, local document/data/SQL and dotenv reads, reference resolution, credential generation, persistent store readers, outbound checks, microphone capture and external VLM requests require operator/admin. Assertion containers also require operator because their specs can read those resources. Screen observation and pure input parsing keep their existing classification. `ac_realize_item`, `ac_idempotency_begin` and `ac_rate_limit` change state and are excluded from `--read-only` mode. + Delayed interval/cron jobs, triggers, hotkeys, watchdog rules, webhooks, e-mail triggers and observer callbacks retain their registrant’s identity. Store-backed users and roles are checked again on dispatch; deleted users, unavailable stores and viewers cannot execute deferred work. Starting a daemon does not transfer its registrations to the starter. Ownership metadata retains no RBAC bearer token; explicit Python `AuthorizationContext` values without `store_path` keep their captured role, and work registered outside RBAC clears worker identity. Observer/watchdog callbacks get fresh variable scopes, and failed watchdog actions do not count as dismissed popups. Nested callbacks retain the caller’s failure count; a webhook with a revoked owner returns HTTP 403 and `fired: false`. ### How the remote-desktop wire protocol works diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index bc7c333ef..501f60d11 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -289,6 +289,8 @@ MCP 窗口状态工具 `ac_window_minimize`、`ac_window_maximize` 与 `ac_windo REST GUI 在用户存储文件启用时显示 RBAC 模式并禁用共用 token 字段。`rest_api_registry.status()` 提供 `rbac_enabled`,该模式的 `token` 为 `null`;请使用个别用户 token。只实现 `check()` 的自制 REST gate 仍可用于共用 token 模式;RBAC 要求已验证的用户 context,会拒绝没有身份的成功结果。 +RBAC viewer 不会取得列管的敏感读取工具,即使 MCP `readOnlyHint` 为 true:剪贴板/历史、本机文档/数据/SQL 与 dotenv 读取、引用解析、认证数据生成、持久存储读取、对外检查、麦克风捕获与外部 VLM 请求都需要 operator/admin。断言容器的 spec 也可能读取这些资源,因此同样需要 operator。屏幕观察与纯输入解析保持既有分类。`ac_realize_item`、`ac_idempotency_begin` 与 `ac_rate_limit` 会改变状态,已从 `--read-only` 模式排除。 + 间隔/cron 调度、触发器、热键、watchdog 规则、webhook、e-mail 触发器与 observer 回调会保留注册者身份。每次触发重新检查来源用户文件与角色;已删除的用户、无法使用的来源文件与 viewer 不能执行后台工作。启动 daemon 不会改变已有工作的所有者。所有者信息不保存 RBAC bearer token;未带 `store_path` 的显式 Python `AuthorizationContext` 保留角色快照,未在 RBAC 下注册的工作会清除 worker 原有身份。observer/watchdog 回调使用独立变量作用域,watchdog 动作失败不会算成成功消除弹窗。嵌套回调会保留调用端的失败计数;webhook 所有者被撤销权限时响应 HTTP 403 与 `fired: false`。 ### 远程桌面的线路协议 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 3b33e4c89..d4006315f 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -288,6 +288,8 @@ MCP 視窗狀態工具 `ac_window_minimize`、`ac_window_maximize` 與 `ac_windo REST GUI 在使用者存放檔啟用時顯示 RBAC 模式並停用共用 token 欄位。`rest_api_registry.status()` 提供 `rbac_enabled`,該模式的 `token` 為 `null`;請使用個別使用者 token。只實作 `check()` 的自製 REST gate 仍可用於共用 token 模式;RBAC 要求已驗證的使用者 context,會拒絕沒有身分的成功結果。 +RBAC viewer 不會取得列管的敏感讀取工具,即使 MCP `readOnlyHint` 為 true:剪貼簿/歷史、本機文件/資料/SQL 與 dotenv 讀取、參照解析、認證資料產生、持久存放區讀取、對外檢查、麥克風擷取與外部 VLM 請求都需要 operator/admin。斷言容器的 spec 也可能讀取這些資源,因此同樣需要 operator。螢幕觀察與純輸入解析維持既有分類。`ac_realize_item`、`ac_idempotency_begin` 與 `ac_rate_limit` 會改變狀態,已從 `--read-only` 模式排除。 + 間隔/cron 排程、觸發器、熱鍵、watchdog 規則、webhook、e-mail 觸發器與 observer 回呼會保留註冊者身分。每次觸發重新檢查來源使用者檔與角色;已刪除的使用者、無法使用的來源檔與 viewer 不能執行背景工作。啟動 daemon 不會改變既有工作的擁有者。擁有者資訊不保存 RBAC bearer token;未帶 `store_path` 的明確 Python `AuthorizationContext` 保留角色快照,未在 RBAC 下註冊的工作會清除 worker 原有身分。observer/watchdog 回呼使用獨立變數範圍,watchdog 動作失敗不會算成成功排除彈窗。巢狀回呼會保留呼叫端的失敗計數;webhook 擁有者被撤銷權限時回應 HTTP 403 與 `fired: false`。 ### 遠端桌面的線路協定 diff --git a/architecture.md b/architecture.md index fca495514..64033bd4b 100644 --- a/architecture.md +++ b/architecture.md @@ -89,6 +89,9 @@ REST gate adaptation preserves check-only custom gates under shared-token auth; store-backed servers require an authenticated user context even from replacement gates. Registry status exposes `rbac_enabled` and omits the inactive shared token so the GUI presents the authentication mode that the server actually accepts. +MCP role decisions also apply explicit sensitive-read overrides independently +of read-only hints; clipboard, local data, credential, external service and +microphone operations require operator authority at both listing and dispatch. Delayed work captures registration authority through `utils/rbac/deferred.py`. Schedulers, triggers, hotkeys, watchdogs, webhooks, e-mail watchers and observers diff --git a/architecture_explore.md b/architecture_explore.md index cc40baa0a..89a3e192c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,080 | -| 程式碼總行數 | 161,997 | +| 程式碼總行數 | 162,056 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -630,7 +630,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.14 安全、機密與合規 -> 13 個套件、約 3,464 行。 +> 13 個套件、約 3,523 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -639,7 +639,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/governance/` | 242 | 治理:maker-checker 核准閘門與即時憑證租約 | | `utils/license_policy/` | 240 | 以 SBOM 元件評估 SPDX 授權允許/拒絕政策 | | `utils/provenance/` | 126 | SLSA 建置來源證明(in-toto v1) | -| `utils/rbac/` | 731 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。 | +| `utils/rbac/` | 790 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | | `utils/redaction/` | 508 | 截圖遮蔽層:規則偵測 + 政策 + 協調器(上傳 VLM 前先遮) | | `utils/sbom/` | 170 | SBOM(CycloneDX)產生 | | `utils/secret_ref/` | 238 | URI scheme 形式的值參照解析 | @@ -1101,6 +1101,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,669 | -| **總計** | **1,074** | **161,932** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,728 | +| **總計** | **1,074** | **161,991** | diff --git a/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst b/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst index 0fb5632af..e85e4b5ac 100644 --- a/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst +++ b/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst @@ -305,9 +305,17 @@ under *Roles* in the operations-layer REST API chapter; both servers can share one file. Without a store nothing changes. The stdio transport has no bearer token and is never subject to RBAC. -- A tool needs ``read_screen`` when it is marked ``readOnlyHint`` and - ``drive_input`` otherwise, so a ``viewer`` gets exactly the read-only - tools and an ``operator`` the rest. ``ac_remote_host_start`` / ``_stop``, +- A tool defaults to ``read_screen`` for ``readOnlyHint`` and ``drive_input`` + otherwise, with explicit capability overrides. Sensitive reads require + ``drive_input`` (operator/admin): clipboard and history, local document/data/SQL + readers, dotenv and reference resolution, JWT/OTP generation, persistent-store + readers, outbound HTTP/port/S3 checks, microphone capture and external VLM + requests. Assertion containers require the same capability because their specs + may read those resources. Pure parsing (for example ``ac_parse_dotenv`` and + ``ac_jwt_decode``) and screen observation keep their existing classification. + Non-mutating hints remain true for these reads; a hint does not grant a role + permission to use them. ``ac_realize_item``, ``ac_idempotency_begin`` and + ``ac_rate_limit`` change state and are excluded from ``--read-only`` mode. ``ac_remote_host_start`` / ``_stop``, ``ac_usb_acl_add`` / ``_remove`` / ``_set_default``, ``ac_usb_passthrough_enable``, ``ac_egress_allow`` / ``_reset`` and ``ac_load_plugins`` need ``manage_hosts`` (``admin``). diff --git a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst index 0f05b68f7..c3fd91131 100644 --- a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst +++ b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst @@ -280,8 +280,14 @@ Bearer token 也可從 ``JE_AUTOCONTROL_MCP_TOKEN`` 環境變數讀取。 同一個檔案。沒有設定存放檔時行為完全不變。stdio 傳輸沒有 bearer token,不受 RBAC 約束。 -- 工具標示 ``readOnlyHint`` 時需要 ``read_screen``,否則需要 ``drive_input``,所以 - ``viewer`` 拿到的正好是唯讀工具,``operator`` 再加上其餘工具。 +- 工具預設依 ``readOnlyHint`` 使用 ``read_screen``,否則為 ``drive_input``,另有明確 + 的 capability 覆寫。敏感讀取需要 ``drive_input`` (operator/admin):剪貼簿與歷史、 + 本機文件/資料/SQL、dotenv 與參照解析、JWT/OTP 產生、持久存放區讀取、對外 + HTTP/port/S3 檢查、麥克風擷取與外部 VLM 請求。斷言容器的 spec 可能讀取相同資源, + 因此也需要該能力。純解析(例如 ``ac_parse_dotenv`` 與 ``ac_jwt_decode``)及螢幕觀察 + 維持既有分類。這些讀取的非修改提示仍為 true;提示本身不會授予角色權限。 + ``ac_realize_item``、``ac_idempotency_begin`` 與 ``ac_rate_limit`` 會改變狀態, + 不會出現在 ``--read-only`` 模式中。 ``ac_remote_host_start``/``_stop``、``ac_usb_acl_add``/``_remove``/ ``_set_default``、``ac_usb_passthrough_enable``、``ac_egress_allow``/``_reset`` 與 ``ac_load_plugins`` 需要 ``manage_hosts``\ (``admin``)。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 8f24fbdcd..0874f591a 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -364,3 +364,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 170 REST/RBAC regression tests pass; 14 final focused/thread-safety cases pass, covering real local HTTP, explicit/environment stores, shared tokens, mode transitions and GUI copy behavior. 75 additional endpoint, schema, metrics and documentation/budget checks pass. Three-platform mypy reports zero error modules; English and Traditional Chinese strict Sphinx builds pass. - **Files**: REST server/registry, GUI REST panel and four language catalogs, regression tests, three READMEs, Sphinx roles guide, changelog and architecture maps. - **Open items**: user management surfaces, sensitive viewer tool classification and real-client/TLS RBAC acceptance remain in `Progress.md`. + +## U-20261018-09 · 2026-10-18 · Separate sensitive MCP reads from viewer observation · #done #mcp #rbac + +- **What**: explicit `drive_input` overrides require operator/admin for 55 non-mutating but sensitive MCP tools: clipboard/history, local document/data/SQL and dotenv reads, reference resolution, JWT/OTP generation, persistent-store readers, outbound checks, microphone capture and external VLM calls. Assertion containers require the same authority because nested specs can access those resources. Pure caller-input parsing and screen observation retain their existing classification; unconfigured RBAC retains existing access. +- **Metadata**: `ac_realize_item`, `ac_idempotency_begin` and `ac_rate_limit` now carry non-read-only annotations, so read-only server mode excludes these state changes. Capability selection is distinct from the non-mutating hint. +- **Validation**: 61 new cases cover real default-tool visibility, rejection before handler invocation, operator/admin execution, unconfigured behavior, read-only filtering and real local HTTP requests with three role tokens. The complete related selection passes 227 tests; 33 documentation/budget checks, three-platform mypy, ruff and bandit pass. The policy module scores 10.00/10 in pylint. English and Traditional Chinese strict Sphinx builds pass. +- **Files**: `utils/rbac/policy.py`, MCP tool annotations, RBAC regression tests, three READMEs, Sphinx MCP roles guide, changelog and architecture maps. +- **Open items**: image-file, mobile and custom/plugin tool capability inventory and real-client RBAC acceptance remain in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4e7d821e2..c40e73ad7 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-09 | 2026-10-18 | Separate sensitive MCP reads from viewer observation | #done #mcp #rbac | [2026-10](2026-10.md) | | U-20261018-08 | 2026-10-18 | Present REST RBAC mode and preserve legacy gate compatibility | #done #rest #rbac | [2026-10](2026-10.md) | | U-20261018-07 | 2026-10-18 | Release factory-built GUI panels on explicit tab close | #done #gui #lifecycle | [2026-10](2026-10.md) | | U-20261018-06 | 2026-10-18 | Isolate CLI previews, planner runs and state-machine variables | #done #executor #variables | [2026-10](2026-10.md) | @@ -371,7 +372,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 35 | +| [2026-10.md](2026-10.md) | 2026-10 | 36 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index fecbc25ae..e7df99603 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1385,7 +1385,7 @@ def a11y_control_tools() -> List[MCPTool]: "automation_id": {"type": "string"}}, required=["item_name"]), handler=h_exec.realize_item, - annotations=READ_ONLY, + annotations=NON_DESTRUCTIVE, ), MCPTool( name="ac_get_element_properties", @@ -6441,7 +6441,7 @@ def rate_limit_tools() -> List[MCPTool]: "capacity": {"type": "number"}, "n": {"type": "number"}}, ["name"]), handler=h_ops.rate_limit, - annotations=READ_ONLY, + annotations=SIDE_EFFECT_ONLY, ), ] @@ -6922,7 +6922,7 @@ def idempotency_tools() -> List[MCPTool]: "request": {"type": "object"}}, ["name", "key"]), handler=h_exec.idempotency_begin, - annotations=READ_ONLY, + annotations=NON_DESTRUCTIVE, ), MCPTool( name="ac_idempotency_complete", diff --git a/je_auto_control/utils/rbac/policy.py b/je_auto_control/utils/rbac/policy.py index b38466e56..7ad5320be 100644 --- a/je_auto_control/utils/rbac/policy.py +++ b/je_auto_control/utils/rbac/policy.py @@ -5,9 +5,10 @@ * **REST routes** are listed one by one. A route missing from the list needs ``manage_hosts``, so a route added without a decision is admin-only rather than open. -* **MCP tools** follow their own annotation -- a tool marked read-only needs - ``read_screen``, any other ``drive_input`` -- except the few named here - that change how the host itself is exposed. +* **MCP tools** default to ``read_screen`` for a read-only annotation and + ``drive_input`` otherwise. Named overrides separate non-mutating sensitive + reads (clipboard, local data, credentials, network and audio) from screen + observation, and protect administration of the host itself. * **``AC_*`` commands** reach the executor through anything that runs an action list (``POST /execute``, ``ac_execute_actions``, an action file). Being allowed to run actions is ``drive_input``; the commands named here @@ -126,6 +127,64 @@ "ac_egress_allow": _HOSTS, "ac_egress_reset": _HOSTS, "ac_load_plugins": _HOSTS, + # A non-mutating hint does not grant access to the desktop user's private + # clipboard, files, credentials, network endpoints or microphone. Assertion + # containers may invoke those same reads through their nested specs. + "ac_get_clipboard": _DRIVE, + "ac_get_clipboard_image": _DRIVE, + "ac_get_clipboard_html": _DRIVE, + "ac_get_clipboard_files": _DRIVE, + "ac_get_clipboard_rtf": _DRIVE, + "ac_get_clipboard_csv": _DRIVE, + "ac_clipboard_formats": _DRIVE, + "ac_clip_history_list": _DRIVE, + "ac_clip_history_search": _DRIVE, + "ac_assert_clipboard": _DRIVE, + "ac_read_action_file": _DRIVE, + "ac_load_dotenv": _DRIVE, + "ac_sql_query": _DRIVE, + "ac_assert_db": _DRIVE, + "ac_read_workbook": _DRIVE, + "ac_read_document": _DRIVE, + "ac_read_presentation": _DRIVE, + "ac_extract_pdf_text": _DRIVE, + "ac_assert_pdf_text": _DRIVE, + "ac_load_data": _DRIVE, + "ac_assert_file": _DRIVE, + "ac_resolve_ref": _DRIVE, + "ac_resolve_refs": _DRIVE, + "ac_jwt_encode": _DRIVE, + "ac_generate_otp": _DRIVE, + "ac_assert_http": _DRIVE, + "ac_wait_for_port": _DRIVE, + "ac_s3_list": _DRIVE, + "ac_assert_all": _DRIVE, + "ac_assert_any": _DRIVE, + "ac_assert_eventually": _DRIVE, + "ac_assert_audio": _DRIVE, + "ac_get_asset": _DRIVE, + "ac_list_assets": _DRIVE, + "ac_cas_get": _DRIVE, + "ac_outbox_pending": _DRIVE, + "ac_checkpoint_status": _DRIVE, + "ac_memory_recall": _DRIVE, + "ac_memory_recent": _DRIVE, + "ac_memory_stats": _DRIVE, + "ac_queue_stats": _DRIVE, + "ac_rank_tests": _DRIVE, + "ac_select_tests": _DRIVE, + "ac_shard_suite": _DRIVE, + "ac_element_find": _DRIVE, + "ac_element_list": _DRIVE, + "ac_skill_list": _DRIVE, + "ac_skill_search": _DRIVE, + "ac_pending_artifacts": _DRIVE, + "ac_approval_status": _DRIVE, + "ac_repair_resolved": _DRIVE, + "ac_repair_pending": _DRIVE, + "ac_vlm_locate": _DRIVE, + "ac_self_heal_locate": _DRIVE, + "ac_describe_screen": _DRIVE, } diff --git a/test/unit_test/headless/test_rbac_sensitive_read_tools.py b/test/unit_test/headless/test_rbac_sensitive_read_tools.py new file mode 100644 index 000000000..2d1f745d0 --- /dev/null +++ b/test/unit_test/headless/test_rbac_sensitive_read_tools.py @@ -0,0 +1,119 @@ +"""Read-only MCP hints do not grant viewers clipboard, local data or credentials.""" +import json +import http.client + +import pytest + +from je_auto_control.utils.mcp_server._authz import FORBIDDEN_CODE +from je_auto_control.utils.mcp_server.http_transport import DEFAULT_PATH, HttpMCPServer +from je_auto_control.utils.mcp_server.server import MCPServer +from je_auto_control.utils.mcp_server.tools import MCPTool, build_default_tool_registry +from je_auto_control.utils.mcp_server.tools._base import READ_ONLY, schema +from je_auto_control.utils.rbac import AuthorizationContext, Role, authorization_scope +from je_auto_control.utils.rbac.users import UserStore + +SENSITIVE = ( + "ac_get_clipboard", "ac_get_clipboard_image", "ac_get_clipboard_html", + "ac_get_clipboard_files", "ac_get_clipboard_rtf", "ac_get_clipboard_csv", + "ac_clipboard_formats", "ac_clip_history_list", "ac_clip_history_search", "ac_assert_clipboard", + "ac_read_action_file", "ac_load_dotenv", "ac_sql_query", "ac_assert_db", + "ac_read_workbook", "ac_read_document", "ac_read_presentation", "ac_extract_pdf_text", + "ac_assert_pdf_text", "ac_load_data", "ac_assert_file", "ac_resolve_ref", "ac_resolve_refs", + "ac_jwt_encode", "ac_generate_otp", "ac_assert_http", "ac_wait_for_port", "ac_s3_list", + "ac_assert_all", "ac_assert_any", "ac_assert_eventually", "ac_assert_audio", + "ac_get_asset", "ac_list_assets", "ac_cas_get", "ac_outbox_pending", "ac_checkpoint_status", + "ac_memory_recall", "ac_memory_recent", "ac_memory_stats", "ac_queue_stats", + "ac_rank_tests", "ac_select_tests", "ac_shard_suite", "ac_element_find", "ac_element_list", + "ac_skill_list", "ac_skill_search", "ac_pending_artifacts", "ac_approval_status", + "ac_repair_resolved", "ac_repair_pending", "ac_vlm_locate", "ac_self_heal_locate", "ac_describe_screen", +) + + +def _rpc(server, method, params=None): + return json.loads(server.handle_line(json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}, + }))) + + +@pytest.fixture(scope="module") +def registry(): + return build_default_tool_registry(read_only=False) + + +def test_default_registry_hides_sensitive_reads_from_viewers(registry): + server = MCPServer(tools=registry) + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + names = {tool["name"] for tool in _rpc(server, "tools/list")["result"]["tools"]} + assert not names.intersection(SENSITIVE) + assert {"ac_get_mouse_position", "ac_screen_size", "ac_parse_dotenv", "ac_jwt_decode"} <= names + # Security classification is separate from readOnlyHint: data reads are + # still non-mutating and pure parsing of caller-supplied values stays visible. + assert all(tool.annotations.read_only for tool in registry if tool.name in SENSITIVE) + + +@pytest.mark.parametrize("name", SENSITIVE) +def test_sensitive_read_call_is_refused_before_handler_invocation(name): + called = [] + tool = MCPTool(name=name, description="controlled sensitive read", annotations=READ_ONLY, + input_schema=schema({}), handler=lambda: called.append(True) or {"ok": True}) + server = MCPServer(tools=[tool]) + for role in Role.all(): + with authorization_scope(AuthorizationContext(role, role)): + names = [item["name"] for item in _rpc(server, "tools/list")["result"]["tools"]] + reply = _rpc(server, "tools/call", {"name": name, "arguments": {}}) + if role == Role.VIEWER: + assert names == [] + assert reply["error"]["code"] == FORBIDDEN_CODE + assert called == [] + else: + assert names == [name] and "result" in reply + assert called == [True, True] + + +def test_unconfigured_rbac_preserves_sensitive_tools(registry): + server = MCPServer(tools=registry) + with authorization_scope(None): + names = {tool["name"] for tool in _rpc(server, "tools/list")["result"]["tools"]} + assert set(SENSITIVE) <= names + + +@pytest.mark.parametrize("name", ["ac_realize_item", "ac_idempotency_begin", "ac_rate_limit"]) +def test_state_changes_are_excluded_from_read_only_mode(name, registry): + tool = next(tool for tool in registry if tool.name == name) + assert tool.annotations.read_only is False + assert name not in {tool.name for tool in build_default_tool_registry(read_only=True)} + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + names = {item["name"] for item in _rpc(MCPServer(tools=[tool]), "tools/list")["result"]["tools"]} + assert names == set() + + +def test_http_user_tokens_cannot_bypass_sensitive_tool_policy(tmp_path): + store = UserStore(tmp_path / "users.json") + tokens = {role: store.add_user(user_id=role, display_name=role, role=role) for role in Role.all()} + called = [] + names = ["ac_sql_query", "ac_get_clipboard", "ac_jwt_encode"] + tools = [MCPTool(name=name, description=name, annotations=READ_ONLY, input_schema=schema({}), + handler=lambda name=name: called.append(name) or {"ok": True}) for name in names] + server = HttpMCPServer(mcp=MCPServer(tools=tools), host="127.0.0.1", port=0, user_store=store) + server.start() + try: + for role, token in tokens.items(): + for name in names: + conn = http.client.HTTPConnection(*server.address, timeout=5) + try: + conn.request("POST", DEFAULT_PATH, json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": {}}, + }), headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}) + response = conn.getresponse() + assert response.status == 200 + reply = json.loads(response.read()) + if role == Role.VIEWER: + assert reply["error"]["code"] == FORBIDDEN_CODE and called == [] + else: + assert "result" in reply + finally: + conn.close() + finally: + server.stop() + assert called == names * 2 diff --git a/test/unit_test/headless/test_rbac_server_wiring.py b/test/unit_test/headless/test_rbac_server_wiring.py index da9d3c548..930102dc7 100644 --- a/test/unit_test/headless/test_rbac_server_wiring.py +++ b/test/unit_test/headless/test_rbac_server_wiring.py @@ -423,5 +423,8 @@ def test_default_registry_offers_a_viewer_only_read_only_tools(users): listed = MCPServer(tools=registry).handle_line( json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})) names = {tool["name"] for tool in json.loads(listed)["result"]["tools"]} - assert names == {tool.name for tool in registry if tool.annotations.read_only} + assert names == {tool.name for tool in registry if tool.annotations.read_only + and capability_for_tool(tool.name, True) == Capability.READ_SCREEN} + assert {"ac_get_mouse_position", "ac_screen_size"} <= names + assert not {"ac_get_clipboard", "ac_sql_query", "ac_load_dotenv", "ac_jwt_encode"} & names assert "ac_execute_actions" not in names and "ac_click_mouse" not in names From 0f525526d82d81418112885effde5097489c1ce6 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 21:30:45 +0800 Subject: [PATCH 010/294] Build redacted and durable structured action journal storage --- CHANGELOG.md | 5 + CLAUDE.md | 6 +- Progress.md | 2 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 6 + architecture_explore.md | 17 +- .../Eng/doc/new_features/new_features_doc.rst | 36 +++ .../Zh/doc/new_features/new_features_doc.rst | 31 ++ docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- .../utils/action_journal/__init__.py | 5 + .../utils/action_journal/events.py | 140 +++++++++ .../utils/action_journal/redaction.py | 103 +++++++ je_auto_control/utils/action_journal/store.py | 140 +++++++++ .../unit_test/headless/test_action_journal.py | 287 ++++++++++++++++++ 17 files changed, 779 insertions(+), 16 deletions(-) create mode 100644 je_auto_control/utils/action_journal/__init__.py create mode 100644 je_auto_control/utils/action_journal/events.py create mode 100644 je_auto_control/utils/action_journal/redaction.py create mode 100644 je_auto_control/utils/action_journal/store.py create mode 100644 test/unit_test/headless/test_action_journal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0ffd8f5..7e273e88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Add the schema-v1 structured action-journal storage foundation: pre-append + secret redaction, fixed outcome-type metadata, interprocess append ordering, + durable JSONL writes and bounded torn-tail recovery. Executor capture and its + facade/command/GUI integration remain pending. + - RBAC viewer access now excludes named sensitive-read tools even when their `readOnlyHint` remains true. Clipboard/history, local data/document/SQL readers, dotenv/reference resolution, credential generators, persistent-store readers, diff --git a/CLAUDE.md b/CLAUDE.md index d7a4010c3..1c08ec92c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ AutoControl (`je_auto_control`) is a cross-platform GUI automation framework: mouse and keyboard control, image recognition, OCR, accessibility-tree and VLM element location, action scripting, and report generation behind one API. Backends: Windows (Win32 ctypes), macOS (pyobjc/Quartz), Linux X11 (python-Xlib), Linux Wayland (libei / ydotool), Android (adb), iOS (WebDriverAgent). - **Package**: `je_auto_control` · **Python** ≥ 3.10 · **License**: MIT · **Author**: JE-Chen -- **[architecture_explore.md](architecture_explore.md)** is the per-module map — read it before changing structure; it lists all 310 `utils/` subpackages, every GUI tab, and file-level tables for the large subsystems. +- **[architecture_explore.md](architecture_explore.md)** is the per-module map — read it before changing structure; it lists all 311 `utils/` subpackages, every GUI tab, and file-level tables for the large subsystems. ## Architecture @@ -18,7 +18,7 @@ AutoControl (`je_auto_control`) is a cross-platform GUI automation framework: mo | Template Method | `utils/generate_report/` | HTML / JSON / XML share collect → format → write. | | Backend seam | `backends/` under `accessibility`, `ocr`, `vision`, `llm`, `agent`, `hotkey`, `usb`, `usbip` | Abstract base + concrete impls + null fallback, so dependency-free environments still import. | -Layering: entry points (`cli.py`, `gui/`, socket / REST / MCP servers) → executor → `utils/` (310 headless subpackages) → `wrapper/` → per-OS backend. +Layering: entry points (`cli.py`, `gui/`, socket / REST / MCP servers) → executor → `utils/` (311 headless subpackages) → `wrapper/` → per-OS backend. ## Development Commands @@ -92,7 +92,7 @@ The map is only useful while it matches the tree, so **update it in the same cha Never adjust one by hand: the counts are `len(text.splitlines())` (what `wc -l` reports), and hand-editing is how the document ended up quoting the same subsystem at two sizes at once — most tables had been counting a phantom trailing line per file while §1 and §8 counted correctly. -- A new `utils/` subpackage needs a row in **exactly one** §5.4 theme table — the tables partition all 310 subpackages; appearing twice or not at all is a defect. +- A new `utils/` subpackage needs a row in **exactly one** §5.4 theme table — the tables partition all 311 subpackages; appearing twice or not at all is a defect. - A new subsystem over ~1,000 lines also needs a file-level table in §5.4.17. - Keep the header's scan date, version, and branch current. - `README.md` and both translations under `README/` cite the same figures (command / subpackage / tab / MCP-tool / example counts) — update all three alongside the map. diff --git a/Progress.md b/Progress.md index fbb78c47d..ab8b290fc 100644 --- a/Progress.md +++ b/Progress.md @@ -12,7 +12,7 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)、C(持久化同步與遠端 session;其中 C4 的擁有者模型已做)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的 executor/並行擷取與完整操作介面、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 diff --git a/README.md b/README.md index 0f13598f3..3382cf8fd 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ desktop app; tab commands live in the window's **Actions** menu. | Diagnostics | `run_diagnostics` | `AC_diagnose` | Diagnostics | | Test-code generation | `generate_code` | — | — | -Beyond this table, `utils/` holds 310 headless packages covering assertions, resilience, +Beyond this table, `utils/` holds 311 headless packages covering assertions, resilience, data quality, i18n auditing, redaction, governance, observability, and more. The full per-module map is in **[architecture_explore.md](architecture_explore.md)**. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 501f60d11..77604567e 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -240,7 +240,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 系统诊断 | `run_diagnostics` | `AC_diagnose` | Diagnostics | | 测试代码生成 | `generate_code` | — | — | -除了这张表,`utils/` 下还有 310 个无头包,覆盖断言、韧性、数据质量、i18n 审计、脱敏、 +除了这张表,`utils/` 下还有 311 个无头包,覆盖断言、韧性、数据质量、i18n 审计、脱敏、 治理、可观测性等等。完整的逐模块地图在 **[architecture_explore.md](../architecture_explore.md)**。 --- diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index d4006315f..e5f66a364 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -239,7 +239,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 系統診斷 | `run_diagnostics` | `AC_diagnose` | Diagnostics | | 測試碼產生 | `generate_code` | — | — | -除了這張表,`utils/` 底下還有 310 個無頭套件,涵蓋斷言、韌性、資料品質、i18n 稽核、遮蔽、 +除了這張表,`utils/` 底下還有 311 個無頭套件,涵蓋斷言、韌性、資料品質、i18n 稽核、遮蔽、 治理、可觀測性等等。完整的逐模組地圖在 **[architecture_explore.md](../architecture_explore.md)**。 --- diff --git a/architecture.md b/architecture.md index 64033bd4b..407bd1409 100644 --- a/architecture.md +++ b/architecture.md @@ -106,6 +106,12 @@ Top-level LLM planner and FSM runs also open a scope; nested invocations reuse an existing one so their actions and states share run-local values. Explicit `Executor()` objects retain their own variables; FSM `context` is separate. +Structured action provenance is being built separately in `utils/action_journal/`. +Its storage foundation uses frozen schema-v1 lifecycle snapshots, pre-write +redaction, interprocess append sequencing and fsync. Readers preserve incomplete +steps and ignore bounded uncommitted tails; opaque outcomes/errors are redacted. +Executor capture and its command/GUI surfaces are not yet connected. + **C. Record → edit → generate code** ``` diff --git a/architecture_explore.md b/architecture_explore.md index 89a3e192c..898116200 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,9 +19,9 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,080 | -| 程式碼總行數 | 162,056 | -| `je_auto_control/utils/` 子套件數 | 310 | +| Python 模組總數(含周邊子專案) | 1,084 | +| 程式碼總行數 | 162,444 | +| `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | | GUI 分頁數(`main_widget` 註冊) | 48 | @@ -55,7 +55,7 @@ USB/IP 協定、Prometheus 指標),以維持這條輕相依基線。 └───────────────────────────────┬──────────────────────────────────────────┘ │ ┌───────────────────────────────▼──────────────────────────────────────────┐ -│ 能力層 utils/(310 個子套件,全部無 Qt 相依) │ +│ 能力層 utils/(311 個子套件,全部無 Qt 相依) │ │ 影像辨識 │ OCR │ 無障礙樹 │ 定位自癒 │ AI/Agent │ 遠端桌面 │ USB │ │ 報表觀測 │ 資料 │ 安全 │ 韌性 │ 系統整合 │ 排程觸發 │ 網路協定 │ └───────────────────────────────┬──────────────────────────────────────────┘ @@ -265,14 +265,14 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `ios/input.py` | 51 | iOS 觸控與按鍵原語。 | | `ios/screen.py` | 34 | iOS 裝置螢幕擷取與尺寸。 | -### 5.4 能力層 `utils/`(310 個子套件) +### 5.4 能力層 `utils/`(311 個子套件) 以下依主題分組。每個子套件都是獨立可匯入的無頭模組,不含任何 Qt 相依。 ### 5.4.1 執行引擎與腳本資產 -> 24 個套件、約 15,234 行。 +> 25 個套件、約 15,622 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -283,6 +283,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/dag/` | 560 | 跨主機 DAG 編排器(圖模型 + runner) | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | +| `utils/action_journal/` | 388 | 結構化動作日誌儲存基礎:schema v1、寫入前遮罩、跨行程排序與中斷尾端復原;executor 擷取與操作介面尚待接入。 | | `utils/executor/` | 9,666 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | @@ -1101,6 +1102,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 690 | 58,728 | -| **總計** | **1,074** | **161,991** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 694 | 59,116 | +| **總計** | **1,078** | **162,379** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 62cd0b251..f6d81e6b5 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1451,3 +1451,39 @@ with ``${secrets.NAME}`` so passwords never appear in the JSON. GUI: **Email Triggers** tab — register IMAP triggers, start/stop the watcher, run a manual poll, inspect last error and fire counter. + +Structured action journal storage (integration pending) +======================================================= + +B1 provides a storage foundation under ``je_auto_control.utils.action_journal``; +executor/parallel capture, facade and command exports, and GUI controls remain +pending integration. Applications can explicitly write schema-v1 snapshots:: + + import time + from dataclasses import replace + from pathlib import Path + from je_auto_control.utils.action_journal import ActionEvent, ActionJournal, read_events + + journal = ActionJournal(Path("actions.jsonl")) + started = ActionEvent( + run_id="review", step_id="step-1", command="AC_set_var", + parameters={"name": "login", "value": "${secrets.LOGIN}"}, + started_at=time.time(), + ) + journal.append(started) # status: incomplete + journal.append(replace(started, status="ok", finished_at=time.time())) + snapshots = read_events(journal.path, run_id="review") + +Every append preserves run/step/parent IDs and receives a file-wide sequence under +an interprocess lock. Complete lines are fsynced; an interrupted final fragment is +ignored by readers and recovered before the next append. Complete malformed lines, +unknown schemas/fields and invalid order raise ``ActionJournalError``. +Records are bounded to 8 MiB of UTF-8 bytes and read one at a time. + +Secret argument names and secret commands are masked before append. Explicit +``${secrets.NAME}`` / ``secret://NAME`` references survive as references. Unsupported +objects, cycles and non-finite values receive non-replayable markers; arbitrary +``repr`` is never invoked. Opaque results and error text are redacted, including +result dictionary keys; ``outcome_type`` preserves a fixed safe type category. +Reading returns lifecycle snapshots, including unfinished ``incomplete`` starts, +and does not infer a completed step from a missing final record. diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index e903e2eaa..ebb054fb1 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1353,3 +1353,34 @@ Watcher 在 process 記憶體中追蹤已觸發的 UID,可選擇把訊息標為 GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher、 手動觸發一次輪詢、檢視最近錯誤與觸發次數。 + +結構化動作日誌儲存(待接入執行流程) +==================================== + +B1 的儲存基礎位於 ``je_auto_control.utils.action_journal``;executor/並行擷取、 +門面與動作指令匯出、GUI 控制仍待接入。應用程式可明確寫入 schema-v1 快照:: + + import time + from dataclasses import replace + from pathlib import Path + from je_auto_control.utils.action_journal import ActionEvent, ActionJournal, read_events + + journal = ActionJournal(Path("actions.jsonl")) + started = ActionEvent( + run_id="review", step_id="step-1", command="AC_set_var", + parameters={"name": "login", "value": "${secrets.LOGIN}"}, + started_at=time.time(), + ) + journal.append(started) # status: incomplete + journal.append(replace(started, status="ok", finished_at=time.time())) + snapshots = read_events(journal.path, run_id="review") + +每次 append 保留 run/step/parent ID,並在跨行程鎖內取得檔案層級的序號。 +完整行會 fsync;讀取時忽略中斷的最後片段,下次 append 前先復原尾端。 +完整但損壞的行、未知 schema/欄位與錯誤順序會拋出 ``ActionJournalError``。 +每筆以 UTF-8 位元組計算,最多 8 MiB,讀取時逐筆處理。 + +秘密參數名稱與秘密指令在 append 前遮罩;明確的 ``${secrets.NAME}`` / ``secret://NAME`` +保留為參照。不支援的物件、循環與非有限數字會有不可重播標記,不呼叫任意物件的 ``repr``。 +不透明結果與錯誤文字會遮罩,包含結果字典的鍵;``outcome_type`` 保留固定的安全型別分類。 +讀取回傳生命週期快照,包含未結束的 ``incomplete`` 起始紀錄,缺少完成紀錄時不推定成功。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 0874f591a..345ef8c67 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -372,3 +372,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 61 new cases cover real default-tool visibility, rejection before handler invocation, operator/admin execution, unconfigured behavior, read-only filtering and real local HTTP requests with three role tokens. The complete related selection passes 227 tests; 33 documentation/budget checks, three-platform mypy, ruff and bandit pass. The policy module scores 10.00/10 in pylint. English and Traditional Chinese strict Sphinx builds pass. - **Files**: `utils/rbac/policy.py`, MCP tool annotations, RBAC regression tests, three READMEs, Sphinx MCP roles guide, changelog and architecture maps. - **Open items**: image-file, mobile and custom/plugin tool capability inventory and real-client RBAC acceptance remain in `Progress.md`. + +## U-20261018-10 · 2026-10-18 · Build versioned, redacted and durable action-journal storage · #done #journal #storage + +- **What**: B1's storage foundation defines frozen schema-v1 `ActionEvent` snapshots, strict validation, `ActionJournal.append` and streaming `read_events`. Run/step/parent/device/session metadata survives; storage assigns file-wide sequences under the existing JSON-store interprocess lock and fsyncs complete lines. It recovers only an unterminated crash fragment, rejects malformed committed records and unknown schemas, and preserves incomplete starts. +- **Redaction**: sensitive arguments and secret commands are masked before the append boundary; explicit secret references survive. Opaque outcomes/error text are omitted, including dynamic result keys, while a fixed outcome-type category remains. Unsupported objects/cycles/non-finite numbers get non-replayable reasons; no arbitrary repr or deepcopy is used. Records are limited to 8 MiB of UTF-8 bytes. +- **Validation**: 42 tests pass, including concurrent instances, real spawned processes, chunk-boundary tail recovery, corrupt/unknown schemas, byte budgets and secret suppression. Scoped coverage is 96.22%; the four modules pass strict mypy and score 10.00/10 in pylint at the repository's 120-column limit. Three-platform full typing and 41 documentation/budget/exception-family checks pass; strict English/Chinese Sphinx and wheel/source builds also pass. The wheel contains all four current journal modules. +- **Files**: new `utils/action_journal/` foundation and tests, measured 311-package documentation, English/Chinese integration reference, changelog and architecture maps. +- **Open items**: B1 executor/parallel capture and facade/command/GUI surfaces, B2 evaluation and B3 journal codegen remain in `Progress.md`; this stage does not enable automatic action capture. diff --git a/docs/updates/README.md b/docs/updates/README.md index c40e73ad7..972ae97a3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-10 | 2026-10-18 | Build versioned, redacted and durable action-journal storage | #done #journal #storage | [2026-10](2026-10.md) | | U-20261018-09 | 2026-10-18 | Separate sensitive MCP reads from viewer observation | #done #mcp #rbac | [2026-10](2026-10.md) | | U-20261018-08 | 2026-10-18 | Present REST RBAC mode and preserve legacy gate compatibility | #done #rest #rbac | [2026-10](2026-10.md) | | U-20261018-07 | 2026-10-18 | Release factory-built GUI panels on explicit tab close | #done #gui #lifecycle | [2026-10](2026-10.md) | @@ -372,7 +373,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 36 | +| [2026-10.md](2026-10.md) | 2026-10 | 37 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/action_journal/__init__.py b/je_auto_control/utils/action_journal/__init__.py new file mode 100644 index 000000000..c52d8b8cd --- /dev/null +++ b/je_auto_control/utils/action_journal/__init__.py @@ -0,0 +1,5 @@ +"""Versioned structured-action journal storage (capture integration is separate).""" +from je_auto_control.utils.action_journal.events import ActionEvent, ActionJournalError +from je_auto_control.utils.action_journal.store import ActionJournal, read_events + +__all__ = ["ActionEvent", "ActionJournal", "ActionJournalError", "read_events"] diff --git a/je_auto_control/utils/action_journal/events.py b/je_auto_control/utils/action_journal/events.py new file mode 100644 index 000000000..9be34d80e --- /dev/null +++ b/je_auto_control/utils/action_journal/events.py @@ -0,0 +1,140 @@ +"""Version-one action event records and strict wire-schema validation.""" +from __future__ import annotations + +import math +from dataclasses import dataclass, fields +from typing import Literal, Optional, cast + +from je_auto_control.utils.exception.exceptions import AutoControlException + +EventStatus = Literal["incomplete", "ok", "error", "cancelled", "skipped", "dry_run"] +_STATUSES = frozenset({"incomplete", "ok", "error", "cancelled", "skipped", "dry_run"}) +OUTCOME_TYPES = frozenset({"none", "boolean", "integer", "number", "text", "object", "array", "unsupported"}) + + +class ActionJournalError(AutoControlException, ValueError): + """A journal event, schema or storage operation is invalid.""" + + +def _text(value: object) -> str: + if not isinstance(value, str) or not value: + raise ActionJournalError("journal identifiers must be non-empty strings") + return value + + +def _optional_text(value: object) -> Optional[str]: + return None if value is None else _text(value) + + +def _integer(value: object) -> int: + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 2 ** 63 - 1: + raise ActionJournalError("journal sequence/version must be a nonnegative 64-bit integer") + return value + + +def _number(value: object) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ActionJournalError("journal timestamps must be finite numbers") + try: + number = float(value) + except OverflowError: + raise ActionJournalError("journal timestamps must be finite numbers") from None + if not math.isfinite(number): + raise ActionJournalError("journal timestamps must be finite numbers") + return number + + +def _optional_number(value: object) -> Optional[float]: + return None if value is None else _number(value) + + +def _status(value: object) -> EventStatus: + if not isinstance(value, str) or value not in _STATUSES: + raise ActionJournalError("unknown journal action status") + return cast(EventStatus, value) + + +def _parameters(value: object) -> dict[str, object]: + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise ActionJournalError("journal parameters must be a string-keyed object") + return dict(value) + + +def _reasons(value: object) -> tuple[str, ...]: + if not isinstance(value, (list, tuple)): + raise ActionJournalError("non-replayable reasons must be a string list") + return tuple(_text(item) for item in value) + + +@dataclass(frozen=True) +class ActionEvent: # pylint: disable=too-many-instance-attributes # reason: fixed versioned wire record + """An action lifecycle snapshot; unfinished actions default to incomplete. + + ``sequence`` is assigned by storage under its interprocess lock. Start and + completion snapshots reuse a step ID and receive separate append sequences. + Parameters and outcomes are copied/sanitized at append time, never via repr. + """ + + run_id: str + step_id: str + command: str + parameters: dict[str, object] + started_at: float + parent_id: Optional[str] = None + finished_at: Optional[float] = None + status: EventStatus = "incomplete" + outcome: object = None + outcome_type: Optional[str] = None + error: Optional[str] = None + artifact: Optional[str] = None + device_id: Optional[str] = None + session_id: Optional[str] = None + sequence: int = 0 + schema_version: int = 1 + non_replayable: tuple[str, ...] = () + + def validate(self) -> None: + """Reject malformed metadata before storage opens or creates any file.""" + for identifier in (self.run_id, self.step_id, self.command): + _text(identifier) + for metadata in (self.parent_id, self.error, self.artifact, self.device_id, self.session_id): + _optional_text(metadata) + if _integer(self.schema_version) != 1: + raise ActionJournalError("unsupported action journal schema version") + _integer(self.sequence) + _parameters(self.parameters) + _reasons(self.non_replayable) + outcome_type = _optional_text(self.outcome_type) + if outcome_type is not None and outcome_type not in OUTCOME_TYPES: + raise ActionJournalError("unknown journal outcome type") + started, finished = _number(self.started_at), _optional_number(self.finished_at) + incomplete = _status(self.status) == "incomplete" + if incomplete != (finished is None) or (finished is not None and finished < started): + raise ActionJournalError("action status and timestamps disagree") + if self.parent_id == self.step_id: + raise ActionJournalError("a journal step cannot be its own parent") + + def to_dict(self) -> dict[str, object]: + """Return field references for sanitization, without deep-copying payloads.""" + return {field.name: getattr(self, field.name) for field in fields(self)} + + @classmethod + def from_dict(cls, value: object) -> ActionEvent: + """Validate a persisted event; unknown fields/versions fail closed.""" + if not isinstance(value, dict) or set(value) != {field.name for field in fields(cls)}: + raise ActionJournalError("invalid action journal event fields") + event = cls( + run_id=_text(value["run_id"]), step_id=_text(value["step_id"]), + command=_text(value["command"]), parameters=_parameters(value["parameters"]), + started_at=_number(value["started_at"]), parent_id=_optional_text(value["parent_id"]), + finished_at=_optional_number(value["finished_at"]), status=_status(value["status"]), + outcome=value["outcome"], outcome_type=_optional_text(value["outcome_type"]), + error=_optional_text(value["error"]), + artifact=_optional_text(value["artifact"]), device_id=_optional_text(value["device_id"]), + session_id=_optional_text(value["session_id"]), sequence=_integer(value["sequence"]), + schema_version=_integer(value["schema_version"]), non_replayable=_reasons(value["non_replayable"]), + ) + event.validate() + if event.sequence < 1: + raise ActionJournalError("persisted journal sequences must be positive") + return event diff --git a/je_auto_control/utils/action_journal/redaction.py b/je_auto_control/utils/action_journal/redaction.py new file mode 100644 index 000000000..185967879 --- /dev/null +++ b/je_auto_control/utils/action_journal/redaction.py @@ -0,0 +1,103 @@ +"""Sanitize journal payloads before any bytes reach the append boundary.""" +from __future__ import annotations + +import math +import re + +from je_auto_control.utils.action_journal.events import ActionEvent +from je_auto_control.utils.executor.action_redaction import is_sensitive_argument + +_REFERENCE = re.compile(r"(?:\$\{secrets\.[A-Za-z_][A-Za-z0-9_.-]*\}|secret://[A-Za-z_][A-Za-z0-9_.-]*)\Z") +_MAX_DEPTH = 32 + + +class _Sanitizer: # pylint: disable=too-few-public-methods # reason: one traversal owns cycle/redaction state + """Copy JSON-compatible values, tracking why a payload cannot be replayed.""" + + def __init__(self) -> None: + self.reasons: set[str] = set() + self._parents: set[int] = set() + + def convert(self, value: object, command: str, *, masked: bool = False, depth: int = 0) -> object: + """Copy input values while honoring command-specific secret argument names.""" + if masked: + return self._masked(value) + if depth > _MAX_DEPTH: + return self._unsupported("payload_depth_limit") + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + return value if math.isfinite(value) else self._unsupported("nonfinite_number") + if isinstance(value, (dict, list, tuple)): + return self._container(value, command, depth) + return self._unsupported("unsupported_payload") + + def _masked(self, value: object) -> str: + if isinstance(value, str) and _REFERENCE.fullmatch(value): + return value + self.reasons.add("redacted_input") + return "***" + + def _unsupported(self, reason: str) -> dict[str, str]: + self.reasons.add(reason) + return {"unsupported": reason} + + def _container(self, value: dict[object, object] | list[object] | tuple[object, ...], + command: str, depth: int) -> object: + identity = id(value) + if identity in self._parents: + return self._unsupported("cyclic_payload") + self._parents.add(identity) + try: + if isinstance(value, dict): + return self._mapping(value, command, depth) + if value and isinstance(value[0], str) and value[0].startswith("AC_"): + secret = _all_secret(value[0]) + return [value[0], *(self.convert(item, value[0], masked=secret, depth=depth + 1) + for item in value[1:])] + return [self.convert(item, command, depth=depth + 1) for item in value] + finally: + self._parents.remove(identity) + + def _mapping(self, value: dict[object, object], command: str, depth: int) -> dict[str, object]: + result: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + self.reasons.add("unsupported_mapping_key") + continue + secret = _all_secret(command) + result[key] = self.convert(item, command, + masked=secret or is_sensitive_argument(command, key), depth=depth + 1) + return result + + +def _all_secret(command: str) -> bool: + return command.startswith("AC_secret_") or command == "AC_write_secret" + + +def sanitize_event(event: ActionEvent) -> dict[str, object]: + """Return a sanitized wire record; opaque outcome/error text is never copied. + + Outcomes may contain resolved secret references or credentials in values or + dynamic keys. Until a typed safe-outcome contract exists, omit their opaque + payloads. Keep source parameters' unresolved secret refs. + """ + sanitizer = _Sanitizer() + payload = event.to_dict() + payload["parameters"] = sanitizer.convert(event.parameters, event.command) + if event.status != "incomplete" and event.outcome_type is None: + payload["outcome_type"] = _outcome_type(event.outcome) + if event.outcome is not None: + payload["outcome"] = "***" + sanitizer.reasons.add("redacted_outcome") + if event.error is not None: + payload["error"] = "***" + sanitizer.reasons.add("redacted_error") + payload["non_replayable"] = sorted(set(event.non_replayable) | sanitizer.reasons) + return payload + + +def _outcome_type(value: object) -> str: + """Expose only a fixed type category, never arbitrary payload class names.""" + return {type(None): "none", bool: "boolean", int: "integer", float: "number", + str: "text", dict: "object", list: "array", tuple: "array"}.get(type(value), "unsupported") diff --git a/je_auto_control/utils/action_journal/store.py b/je_auto_control/utils/action_journal/store.py new file mode 100644 index 000000000..36a5f6ed7 --- /dev/null +++ b/je_auto_control/utils/action_journal/store.py @@ -0,0 +1,140 @@ +"""Append-only action JSONL with interprocess order and torn-tail recovery.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import BinaryIO, Iterator, Optional + +from je_auto_control.utils.action_journal.events import ActionEvent, ActionJournalError +from je_auto_control.utils.action_journal.redaction import sanitize_event +from je_auto_control.utils.json_store.json_store import _file_lock, append_json_line + +_MAX_RECORD_BYTES = 8 * 1024 * 1024 + + +class ActionJournal: + """Persist lifecycle snapshots in one ordered journal shared by writers. + + Each append redacts before acquiring a file handle, assigns sequence under + the shared JSON-store lock, appends one complete line and fsyncs it. Only an + unterminated crash fragment is discarded on recovery; committed lines stay. + """ + + def __init__(self, path: Path) -> None: + try: + self._path = Path(path) + except (OSError, TypeError, ValueError): + raise ActionJournalError("invalid action journal path") from None + + @property + def path(self) -> Path: + """The explicit journal destination; no global log is opened implicitly.""" + return self._path + + def append(self, event: ActionEvent) -> None: + """Validate and sanitize an event before appending it durably.""" + event.validate() + payload = sanitize_event(event) + # Validate encoding/size before creating directories or lock files. + _encode(payload) + try: + with _file_lock(self.path): + payload["sequence"] = _next_sequence(self.path) + append_json_line(self.path, _encode(payload)) + with self.path.open("ab") as handle: + os.fsync(handle.fileno()) + except (OSError, ValueError) as error: + raise ActionJournalError("could not append action journal") from error + + +def _encode(payload: dict[str, object]) -> str: + try: + line = json.dumps(payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + size = len(line.encode("utf-8")) + except (TypeError, ValueError): + raise ActionJournalError("invalid action journal payload encoding") from None + if size > _MAX_RECORD_BYTES: + raise ActionJournalError("action journal record exceeds 8 MiB") + return line + + +def _next_sequence(path: Path) -> int: + """Read only the tail, discarding an uncommitted fragment under the lock.""" + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600) + with os.fdopen(fd, "a+b") as handle: + offset, tail = _tail(handle) + if tail and not tail.endswith(b"\n"): + boundary = tail.rfind(b"\n") + 1 + handle.truncate(offset + boundary) + tail = tail[:boundary] + if not tail: + return 1 + raw = tail[:-1].rsplit(b"\n", 1)[-1] + sequence = _decode(raw, 0).sequence + 1 + if sequence > 2 ** 63 - 1: + raise ActionJournalError("action journal sequence exhausted") + return sequence + + +def _tail(handle: BinaryIO) -> tuple[int, bytes]: + position = handle.seek(0, os.SEEK_END) + chunks: list[bytes] = [] + separators = total = 0 + while position > 0 and separators < 2: + size = min(4096, position) + position -= size + handle.seek(position) + chunk = handle.read(size) + chunks.append(chunk) + separators += chunk.count(b"\n") + total += len(chunk) + if total > 2 * _MAX_RECORD_BYTES + 4096: + raise ActionJournalError("action journal tail exceeds record limit") + return position, b"".join(reversed(chunks)) + + +def _decode(raw: bytes, line_number: int) -> ActionEvent: + try: + payload: object = json.loads(raw.decode("utf-8")) + return ActionEvent.from_dict(payload) + except (ValueError, TypeError, UnicodeError): + # Do not include raw data or JSON decoder messages: external journals + # may contain secrets even though this writer never emits those bytes. + raise ActionJournalError(f"invalid action journal record at line {line_number}") from None + + +def read_events(path: Path, *, run_id: Optional[str] = None) -> list[ActionEvent]: + """Read validated snapshots in append order, optionally selecting one run. + + A final unterminated fragment is uncommitted and ignored. Malformed complete + lines, unsupported schemas and non-increasing sequences are rejected. Start + snapshots remain incomplete; reading never infers successful completion. + """ + try: + with Path(path).open("rb") as handle: + return [event for event in _snapshots(handle) if run_id is None or event.run_id == run_id] + except FileNotFoundError: + return [] + except (OSError, TypeError, ValueError) as error: + if isinstance(error, ActionJournalError): + raise + raise ActionJournalError("could not read action journal") from error + + +def _snapshots(handle: BinaryIO) -> Iterator[ActionEvent]: + """Stream complete, bounded JSON lines and validate ordering across all runs.""" + previous = 0 + index = 0 + while True: + line = handle.readline(_MAX_RECORD_BYTES + 2) + if len(line) > _MAX_RECORD_BYTES + 1: + raise ActionJournalError("action journal record exceeds 8 MiB") + if not line.endswith(b"\n"): + return # EOF or the uncommitted final fragment + index += 1 + event = _decode(line, index) + if event.sequence <= previous: + raise ActionJournalError(f"unordered action journal record at line {index}") + previous = event.sequence + yield event diff --git a/test/unit_test/headless/test_action_journal.py b/test/unit_test/headless/test_action_journal.py new file mode 100644 index 000000000..9e448fbe5 --- /dev/null +++ b/test/unit_test/headless/test_action_journal.py @@ -0,0 +1,287 @@ +"""Versioned journal storage never persists secrets or upgrades unfinished steps.""" +import json +import multiprocessing +import threading +from dataclasses import FrozenInstanceError, replace + +import pytest + +from je_auto_control.utils.action_journal import ActionEvent, ActionJournal, ActionJournalError, read_events + + +def _event(**changes): + event = ActionEvent(run_id="run", step_id="step", command="AC_probe", + parameters={"value": "input"}, started_at=1.0) + return replace(event, **changes) + + +def test_event_is_frozen_and_unfinished_is_not_success(tmp_path): + event = _event() + with pytest.raises(FrozenInstanceError): + event.status = "ok" + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(event) + loaded = read_events(journal.path) + assert loaded[0].status == "incomplete" and loaded[0].finished_at is None + assert loaded[0].schema_version == 1 and loaded[0].sequence == 1 + + +def test_redaction_precedes_append_and_keeps_secret_references(tmp_path, monkeypatch): + from je_auto_control.utils.action_journal import store + seen = [] + original = store.append_json_line + + def append(path, line): + seen.append(line) + assert "literal-password" not in line and "literal-token" not in line + original(path, line) + + monkeypatch.setattr(store, "append_json_line", append) + journal = ActionJournal(tmp_path / "events.jsonl") + event = _event(parameters={"password": "literal-password", "headers": {"Authorization": "literal-token"}, + "text": "${secrets.LOGIN}"}) + journal.append(event) + assert seen and "${secrets.LOGIN}" in seen[0] + assert event.parameters["password"] == "literal-password" + assert read_events(journal.path)[0].parameters["password"] == "***" + + +def test_secret_command_outcome_and_error_are_not_persisted(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(command="AC_secret_set", parameters={"name": "login", "value": "hidden"}, + outcome={"echo": "hidden"}, error="hidden in error", + status="error", finished_at=2.0)) + assert "hidden" not in journal.path.read_text(encoding="utf-8") + event = read_events(journal.path)[0] + assert event.outcome == "***" and event.error == "***" + assert event.non_replayable + + +def test_sensitive_input_is_also_masked_from_echoed_outcomes_and_errors(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(parameters={"password": "never-persist-this"}, + outcome={"message": "echo never-persist-this"}, error="bad never-persist-this", + status="error", finished_at=2.0)) + assert "never-persist-this" not in journal.path.read_text(encoding="utf-8") + + +def test_opaque_outcome_keys_cannot_leak_credentials(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(outcome={"private-result": {"another-credential": "anything"}}, + status="ok", finished_at=2.0)) + text = journal.path.read_text(encoding="utf-8") + assert "private-result" not in text and "another-credential" not in text + event = read_events(journal.path)[0] + assert "redacted_outcome" in event.non_replayable and event.outcome_type == "object" + + +def test_sensitive_fields_can_retain_explicit_secret_references(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(parameters={"token": "${secrets.LOGIN}", "password": "secret://LOGIN"})) + assert read_events(journal.path)[0].parameters == {"token": "${secrets.LOGIN}", "password": "secret://LOGIN"} + + +def test_nested_secret_actions_mask_even_nonstandard_argument_shapes(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(parameters={"body": [["AC_secret_unlock", "private-passphrase"]]})) + assert "private-passphrase" not in journal.path.read_text(encoding="utf-8") + + +def test_cycles_and_nonfinite_payloads_get_non_replayable_markers(tmp_path): + cycle = [] + cycle.append(cycle) + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(parameters={"cycle": cycle, "number": float("nan")})) + assert {"cyclic_payload", "nonfinite_number"} <= set(read_events(journal.path)[0].non_replayable) + + +def test_unknown_payload_is_not_serialized_using_repr(tmp_path): + class Payload: + def __repr__(self): + raise AssertionError("must never inspect arbitrary repr") + + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(parameters={"payload": Payload()})) + event = read_events(journal.path)[0] + assert event.non_replayable and "unsupported" in str(event.parameters) + + +def test_run_filter_and_parent_links_survive_round_trip(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(step_id="parent")) + journal.append(_event(step_id="child", parent_id="parent")) + journal.append(_event(run_id="another", step_id="foreign")) + events = read_events(journal.path, run_id="run") + assert [event.step_id for event in events] == ["parent", "child"] + assert events[1].parent_id == "parent" + assert [event.sequence for event in read_events(journal.path)] == [1, 2, 3] + + +def test_parallel_writers_share_one_order_even_across_instances(tmp_path): + path = tmp_path / "events.jsonl" + barrier = threading.Barrier(4, timeout=5) + failures = [] + + def write(index): + try: + journal = ActionJournal(path) + barrier.wait() + for step in range(8): + journal.append(_event(step_id=f"{index}-{step}", parent_id="parent")) + except Exception as error: # reason: surface worker failures on the test thread + failures.append(error) + + threads = [threading.Thread(target=write, args=(index,)) for index in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=15) + assert not any(thread.is_alive() for thread in threads) and failures == [] + events = read_events(path) + assert len(events) == 32 and [event.sequence for event in events] == list(range(1, 33)) + assert all(event.run_id == "run" and event.parent_id == "parent" for event in events) + + +def test_partial_tail_does_not_complete_a_step_and_append_recovers_it(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event()) + with journal.path.open("ab") as handle: + handle.write(b'{"schema_version":1,"status":"ok"') + assert read_events(journal.path)[0].status == "incomplete" + journal.append(_event(status="ok", finished_at=2.0)) + events = read_events(journal.path) + assert [event.status for event in events] == ["incomplete", "ok"] + assert [event.sequence for event in events] == [1, 2] + + +@pytest.mark.parametrize("change", [ + {"schema_version": 2}, {"run_id": ""}, {"step_id": ""}, {"command": ""}, + {"status": "success-ish"}, {"status": "ok"}, {"finished_at": 0.0, "status": "ok"}, + {"started_at": float("nan")}, {"sequence": -1}, + {"started_at": 10 ** 400}, {"sequence": 2 ** 63}, {"parent_id": "step"}, + {"started_at": True}, {"parameters": []}, {"non_replayable": "reason"}, +]) +def test_invalid_events_fail_before_any_write(tmp_path, change): + journal = ActionJournal(tmp_path / "events.jsonl") + with pytest.raises(ActionJournalError): + journal.append(_event(**change)) + assert not journal.path.exists() + + +@pytest.mark.parametrize("change", [ + {"schema_version": 2}, {"extra": "unknown"}, {"sequence": True}, {"sequence": 0}, +]) +def test_reader_refuses_unknown_schema_and_fields(tmp_path, change): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event()) + row = json.loads(journal.path.read_text(encoding="utf-8")) + row.update(change) + journal.path.write_text(json.dumps(row) + "\n", encoding="utf-8") + with pytest.raises(ActionJournalError): + read_events(journal.path) + + +def test_missing_journal_is_empty_and_malformed_complete_line_is_refused(tmp_path): + path = tmp_path / "events.jsonl" + assert read_events(path) == [] + path.write_text('{"bad":\n', encoding="utf-8") + with pytest.raises(ActionJournalError): + read_events(path) + + +def test_control_characters_cannot_hide_a_corrupt_complete_record(tmp_path): + path = tmp_path / "events.jsonl" + path.write_bytes(b'\rnot-json\n') + with pytest.raises(ActionJournalError): + read_events(path) + + +def test_reader_checks_sequence_even_for_runs_it_filters_out(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event()) + row = json.loads(journal.path.read_text(encoding="utf-8")) + row["run_id"] = "other" + with journal.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row) + "\n") + with pytest.raises(ActionJournalError, match="unordered"): + read_events(journal.path, run_id="run") + + +@pytest.mark.parametrize("value", ["\ud800", 10 ** 5000], ids=["invalid-unicode", "oversized-integer"]) +def test_invalid_json_encodings_are_framework_errors_before_write(tmp_path, value): + journal = ActionJournal(tmp_path / "events.jsonl") + with pytest.raises(ActionJournalError, match="encoding"): + journal.append(_event(parameters={"value": value})) + assert not journal.path.exists() + + +def test_sequence_exhaustion_does_not_append_an_unreadable_record(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event()) + row = json.loads(journal.path.read_text(encoding="utf-8")) + row["sequence"] = 2 ** 63 - 1 + journal.path.write_text(json.dumps(row) + "\n", encoding="utf-8") + with pytest.raises(ActionJournalError): + journal.append(_event()) + assert len(read_events(journal.path)) == 1 + + +def test_large_rows_and_a_partial_tail_recover_across_chunk_boundaries(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event(parameters={"text": "多語系" * 4000})) + with journal.path.open("ab") as handle: + handle.write(b'{"partial":' + b"x" * 9000) + journal.append(_event(step_id="next")) + assert [event.sequence for event in read_events(journal.path)] == [1, 2] + + +def _process_append(path, ready, start, index): + journal = ActionJournal(path) + ready.set() + if not start.wait(timeout=15): + raise RuntimeError("journal worker was never released") + for step in range(12): + journal.append(_event(step_id=f"process-{index}-{step}")) + + +def test_processes_share_a_monotonic_append_order(tmp_path): + context = multiprocessing.get_context("spawn") + start = context.Event() + ready = [context.Event(), context.Event()] + path = tmp_path / "events.jsonl" + processes = [context.Process(target=_process_append, args=(path, ready[index], start, index)) + for index in range(2)] + try: + for process in processes: + process.start() + assert all(event.wait(timeout=15) for event in ready) + start.set() + for process in processes: + process.join(timeout=20) + assert process.exitcode == 0 + finally: + start.set() + for process in processes: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + process.close() + assert [event.sequence for event in read_events(path)] == list(range(1, 25)) + + +def test_record_budget_uses_encoded_bytes_and_fails_before_write(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + with pytest.raises(ActionJournalError, match="8 MiB"): + journal.append(_event(parameters={"text": "界" * 3_000_000})) + assert not journal.path.exists() + + +@pytest.mark.parametrize("operation", ["read", "append"]) +def test_storage_errors_stay_in_the_framework_exception_family(tmp_path, operation): + destination = tmp_path / "directory" + destination.mkdir() + with pytest.raises(ActionJournalError): + if operation == "read": + read_events(destination) + else: + ActionJournal(destination).append(_event()) From 430bb884f5cbba6ab9e78110f95fdf1979ec6acf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 22:13:04 +0800 Subject: [PATCH 011/294] Capture executor actions with durable parent provenance --- CHANGELOG.md | 11 + Progress.md | 5 +- README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 7 +- architecture_explore.md | 24 +-- .../Eng/doc/new_features/new_features_doc.rst | 30 ++- .../Zh/doc/new_features/new_features_doc.rst | 26 ++- docs/updates/2026-10.md | 17 ++ docs/updates/README.md | 4 +- .../utils/action_journal/__init__.py | 8 +- .../utils/action_journal/capture.py | 172 +++++++++++++++ je_auto_control/utils/action_journal/store.py | 39 +++- je_auto_control/utils/dag/runner.py | 4 +- .../utils/executor/action_executor.py | 36 ++-- .../utils/executor/flow_control.py | 4 +- .../unit_test/headless/test_action_journal.py | 35 ++++ .../headless/test_action_journal_capture.py | 196 ++++++++++++++++++ 19 files changed, 576 insertions(+), 48 deletions(-) create mode 100644 je_auto_control/utils/action_journal/capture.py create mode 100644 test/unit_test/headless/test_action_journal_capture.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e273e88b..4f96511dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased + +- Journal tail recovery validates the destination's format and last committed + event before truncation. Existing non-journal files, corrupt committed tails + and ambiguous first fragments remain intact and report a format refusal. + +- Explicit Python journal scopes now capture executor starts/outcomes, including + local loop/parallel/DAG parent relationships. Failed, cancelled and dry-run + steps remain distinct; process interruption leaves a durable incomplete start. + The convenience runner isolates variables and returns typed run metadata. + Facade, command, CLI/MCP/GUI and run-history integration remain pending. + - Add the schema-v1 structured action-journal storage foundation: pre-append secret redaction, fixed outcome-type metadata, interprocess append ordering, durable JSONL writes and bounded torn-tail recovery. Executor capture and its diff --git a/Progress.md b/Progress.md index ab8b290fc..2c15da623 100644 --- a/Progress.md +++ b/Progress.md @@ -10,9 +10,11 @@ 實作計畫:[分階段交付計畫](docs/superpowers/plans/2026-10-02-modernization-index.md),待審閱。 現有 `[Answer]` 決策沿用。 +`TODO` — 真機與付費 API 驗收暫緩,待明確恢復後執行;相關項目保留待驗證。 + `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的 executor/並行擷取與完整操作介面、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的門面/動作指令/CLI/MCP/GUI 介面與 run-history 連結、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 @@ -220,6 +222,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 +- **既有應用程式日誌的秘密遮罩**:`utils/executor/action_executor.py` 的結果與例外記錄仍直接格式化 outcome/`repr(error)`;需在該邊界遮罩 `${secrets.*}` 的解析後值,避免寫入應用程式日誌。 - **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index 3382cf8fd..0bdfc863f 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. --- +Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Facade, command, CLI/MCP and GUI controls remain pending integration. + ## Command-line interface Each `je_auto_control run` invocation, including `--dry-run` and runs without `--var`, gets a fresh variable scope. Top-level `run_from_description` and `StateMachine.run()` also isolate variables while sharing them across their own actions and states; nested calls reuse an existing execution scope. A caller-owned `Executor()` keeps its own variables. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 77604567e..823e33496 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -245,6 +245,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- +结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。门面、动作命令、CLI/MCP 与 GUI 控制仍待接入。 + ## 命令行界面 每次 `je_auto_control run`(包括 `--dry-run` 与未带 `--var`)使用独立变量作用域。顶层 `run_from_description` 与 `StateMachine.run()` 也会隔离变量,同一次执行内的动作与状态仍可共享;嵌套调用沿用已有的执行作用域。自行创建的 `Executor()` 保留自己的变量。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index e5f66a364..46417c6e6 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -244,6 +244,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- +結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。門面、動作指令、CLI/MCP 與 GUI 控制仍待接入。 + ## 命令列介面 每次 `je_auto_control run`(包括 `--dry-run` 與未帶 `--var`)使用獨立變數範圍。頂層 `run_from_description` 與 `StateMachine.run()` 也會隔離變數,同一次執行內的動作與狀態仍可共享;巢狀呼叫沿用已有的執行範圍。自行建立的 `Executor()` 保留自己的變數。 diff --git a/architecture.md b/architecture.md index 407bd1409..5797ac9a5 100644 --- a/architecture.md +++ b/architecture.md @@ -110,7 +110,12 @@ Structured action provenance is being built separately in `utils/action_journal/ Its storage foundation uses frozen schema-v1 lifecycle snapshots, pre-write redaction, interprocess append sequencing and fsync. Readers preserve incomplete steps and ignore bounded uncommitted tails; opaque outcomes/errors are redacted. -Executor capture and its command/GUI surfaces are not yet connected. +Explicit `journal_scope` capture wraps each executor dispatch; loop bodies keep +their step parent and local parallel/DAG workers rebind the same run metadata. +`execute_action_with_journal` opens a fresh variable scope and returns a typed +run summary. Capture starts are durable before effects; failed actions, handled +loop signals, cancellation and dry-runs receive distinct completion statuses. +Facade/command/CLI/MCP/GUI surfaces and run-history linking remain pending. **C. Record → edit → generate code** diff --git a/architecture_explore.md b/architecture_explore.md index 898116200..049546973 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,8 +19,8 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,084 | -| 程式碼總行數 | 162,444 | +| Python 模組總數(含周邊子專案) | 1,085 | +| 程式碼總行數 | 162,653 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,622 行。 +> 25 個套件、約 15,831 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -280,11 +280,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/action_signing/` | 716 | action 檔 HMAC-SHA256 簽章與 Fernet 加密,`execute_files` 會強制驗簽 | | `utils/checkpoint/` | 129 | 流程檢查點與續跑,讓長 action list 具持久性 | | `utils/codegen/` | 294 | 由 action list 產生可執行的 pytest / python / robot 測試碼 | -| `utils/dag/` | 560 | 跨主機 DAG 編排器(圖模型 + runner) | +| `utils/dag/` | 562 | 跨主機 DAG 編排器(圖模型 + runner) | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | -| `utils/action_journal/` | 388 | 結構化動作日誌儲存基礎:schema v1、寫入前遮罩、跨行程排序與中斷尾端復原;executor 擷取與操作介面尚待接入。 | -| `utils/executor/` | 9,666 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/action_journal/` | 585 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | +| `utils/executor/` | 9,676 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -697,12 +697,12 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,666 行)— 執行核心 +#### `utils/executor/`(9,676 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,435 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | -| `flow_control.py` | 656 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | +| `action_executor.py` | 8,443 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `flow_control.py` | 658 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | | `action_schema.py` | 159 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | | `action_redaction.py` | 86 | 記錄與紀錄鍵用的遮蔽:`AC_secret_*` 的參數(金庫通行碼、機密值)在寫進 log、當成結果紀錄的鍵之前換成 `***`,巢狀在區塊指令裡的也一樣。 | @@ -1085,7 +1085,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `gui/` | 100 | 28,788 | | `utils/mcp_server/` | 37 | 19,164 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,666 | +| `utils/executor/` | 8 | 9,676 | | `utils/usb/` | 17 | 4,816 | | `je_auto_control/`(頂層 3 檔) | 3 | 2,459 | | `utils/accessibility/` | 14 | 3,143 | @@ -1102,6 +1102,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 694 | 59,116 | -| **總計** | **1,078** | **162,379** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 695 | 59,315 | +| **總計** | **1,079** | **162,588** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index f6d81e6b5..7ac72d47b 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1456,7 +1456,7 @@ Structured action journal storage (integration pending) ======================================================= B1 provides a storage foundation under ``je_auto_control.utils.action_journal``; -executor/parallel capture, facade and command exports, and GUI controls remain +facade and command exports, CLI/MCP/GUI controls and run-history linking remain pending integration. Applications can explicitly write schema-v1 snapshots:: import time @@ -1475,8 +1475,10 @@ pending integration. Applications can explicitly write schema-v1 snapshots:: snapshots = read_events(journal.path, run_id="review") Every append preserves run/step/parent IDs and receives a file-wide sequence under -an interprocess lock. Complete lines are fsynced; an interrupted final fragment is -ignored by readers and recovered before the next append. Complete malformed lines, +an interprocess lock. Complete lines are fsynced; a final fragment after a valid +record is ignored by readers and recovered before the next append. Existing +non-journal files and ambiguous torn first records are refused without truncation; +both the file identity and preceding complete record are validated before repair. Complete malformed lines, unknown schemas/fields and invalid order raise ``ActionJournalError``. Records are bounded to 8 MiB of UTF-8 bytes and read one at a time. @@ -1487,3 +1489,25 @@ objects, cycles and non-finite values receive non-replayable markers; arbitrary result dictionary keys; ``outcome_type`` preserves a fixed safe type category. Reading returns lifecycle snapshots, including unfinished ``incomplete`` starts, and does not infer a completed step from a missing final record. + +Explicit executor capture is also available:: + + from pathlib import Path + from je_auto_control.utils.action_journal import execute_action_with_journal, read_events + + summary = execute_action_with_journal( + [["AC_set_var", {"name": "count", "value": 1}]], + Path("captured.jsonl"), run_id="demo", dry_run=True, + ) + snapshots = read_events(Path(summary["journal_path"]), run_id=summary["run_id"]) + +``journal_scope(ActionJournal(path))`` captures existing executor calls without +changing a private executor's variable ownership. The convenience runner uses +the module executor in fresh variables and returns ``JournalRunResult`` with +``run_id``, ``journal_path``, the original ``record`` and ``failed_actions``. +Local loop, parallel and default DAG workers preserve the run and parent step; +remote DAG execution requires separate capture on the remote endpoint. +Soft/strict failures, cancellation and ``dry_run`` stay distinct. ``ok`` reports +dispatch completion; domain verification is a separate check. A process killed +mid-command retains its durable incomplete start. Completion-storage failure +preserves an original cancellation/error and leaves the start incomplete. diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index ebb054fb1..bb27c9593 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1357,8 +1357,8 @@ GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher 結構化動作日誌儲存(待接入執行流程) ==================================== -B1 的儲存基礎位於 ``je_auto_control.utils.action_journal``;executor/並行擷取、 -門面與動作指令匯出、GUI 控制仍待接入。應用程式可明確寫入 schema-v1 快照:: +B1 的儲存與明確擷取位於 ``je_auto_control.utils.action_journal``;門面與動作指令匯出、 +CLI/MCP/GUI 控制、run-history 連結仍待接入。應用程式可明確寫入 schema-v1 快照:: import time from dataclasses import replace @@ -1376,7 +1376,8 @@ B1 的儲存基礎位於 ``je_auto_control.utils.action_journal``;executor/ snapshots = read_events(journal.path, run_id="review") 每次 append 保留 run/step/parent ID,並在跨行程鎖內取得檔案層級的序號。 -完整行會 fsync;讀取時忽略中斷的最後片段,下次 append 前先復原尾端。 +完整行會 fsync;已有有效紀錄時,讀取會忽略中斷的最後片段,下次 append 前先復原尾端。 +既有非日誌檔與無法辨識的首筆片段會被拒絕且保持原內容;復原前先驗證檔案身分與上一筆完整紀錄。 完整但損壞的行、未知 schema/欄位與錯誤順序會拋出 ``ActionJournalError``。 每筆以 UTF-8 位元組計算,最多 8 MiB,讀取時逐筆處理。 @@ -1384,3 +1385,22 @@ B1 的儲存基礎位於 ``je_auto_control.utils.action_journal``;executor/ 保留為參照。不支援的物件、循環與非有限數字會有不可重播標記,不呼叫任意物件的 ``repr``。 不透明結果與錯誤文字會遮罩,包含結果字典的鍵;``outcome_type`` 保留固定的安全型別分類。 讀取回傳生命週期快照,包含未結束的 ``incomplete`` 起始紀錄,缺少完成紀錄時不推定成功。 + +亦可明確擷取 executor 執行:: + + from pathlib import Path + from je_auto_control.utils.action_journal import execute_action_with_journal, read_events + + summary = execute_action_with_journal( + [["AC_set_var", {"name": "count", "value": 1}]], + Path("captured.jsonl"), run_id="demo", dry_run=True, + ) + snapshots = read_events(Path(summary["journal_path"]), run_id=summary["run_id"]) + +``journal_scope(ActionJournal(path))`` 可擷取現有 executor 呼叫,不改變私有 executor 的變數所有權。 +便利 runner 使用模組 executor 與新的變數範圍,回傳含 ``run_id``、``journal_path``、 +原始 ``record`` 與 ``failed_actions`` 的 ``JournalRunResult``。 +本機 loop、parallel 與預設 DAG worker 會保留 run 與 parent step;遠端 DAG 端點需另行擷取。 +一般/嚴格失敗、取消與 ``dry_run`` 分別記錄;``ok`` 表示 dispatch 完成,業務結果需另外驗證。 +行程在動作中被結束時,保留已落盤的未完成起始紀錄。 +完成紀錄無法寫入時會保留原本的取消/錯誤,起始紀錄維持未完成。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 345ef8c67..232bbaefe 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -380,3 +380,20 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 42 tests pass, including concurrent instances, real spawned processes, chunk-boundary tail recovery, corrupt/unknown schemas, byte budgets and secret suppression. Scoped coverage is 96.22%; the four modules pass strict mypy and score 10.00/10 in pylint at the repository's 120-column limit. Three-platform full typing and 41 documentation/budget/exception-family checks pass; strict English/Chinese Sphinx and wheel/source builds also pass. The wheel contains all four current journal modules. - **Files**: new `utils/action_journal/` foundation and tests, measured 311-package documentation, English/Chinese integration reference, changelog and architecture maps. - **Open items**: B1 executor/parallel capture and facade/command/GUI surfaces, B2 evaluation and B3 journal codegen remain in `Progress.md`; this stage does not enable automatic action capture. + +→ corrected in U-20261018-11. + +## U-20261018-11 · 2026-10-18 · Preserve foreign files when recovering journal tails · #incident #journal #storage + +- **Finding**: controlled temporary-file tests exposed that the initial B1 tail recovery truncated an unterminated destination before verifying its format and preceding complete row. A mistaken non-journal path or corrupt committed row could therefore lose bytes even when append subsequently failed. +- **Correction**: validate the first journal record and last complete record before any truncation. Foreign destinations and ambiguous torn first records are refused unchanged; a corrupt complete tail also blocks repair. Readers refuse ambiguous first fragments, and format errors retain their actionable framework exception message. +- **Validation**: seven new cases cover foreign text/JSON/multiline files, torn first records, corrupt committed tails and preserved refusal reasons; the 157-test post-fix journal/scope/flow selection passes. +- **Files**: `utils/action_journal/store.py`, `test_action_journal.py`, journal references and changelog. Corrects the recovery guarantee in U-20261018-10. + +## U-20261018-12 · 2026-10-18 · Capture executor action lifecycles with worker parent links · #done #journal #executor + +- **What**: explicit `journal_scope` captures executor lifecycle starts and outcomes; `execute_action_with_journal` opens fresh variables and returns typed run metadata with the original record and failure delta. Loop bodies keep parent IDs; local parallel and default DAG workers explicitly rebind run/device/session metadata. Source inputs are copied and sanitized before dispatch. Opaque results/errors remain redacted. +- **Outcomes**: soft/strict failures, expected loop signals, cancellation and dry-runs receive distinct statuses. Starts are fsynced before device effects. Killing a process in a controlled command leaves only an incomplete start; a failed completion write preserves an original exception and leaves its start incomplete. Plain unscoped calls keep their original result contract and open no journal. +- **Validation**: 16 new capture cases pass. Before the final tail safeguard, the full coverage run passed 11,620 tests with 69 skipped and 87.65% coverage. After the safeguard, the 157-test focused selection passes with 96.08% scoped journal coverage. Jeffrey_RPA's three facade/control suites pass 751 tests against this working tree (five existing Discord deprecations). New modules pass strict mypy; the full three-platform typing gate passes. The facade remains Qt-free. Another 41 documentation/budget/exception checks, strict Sphinx builds and wheel/source builds pass; pylint scores 10.00/10 using the repository limits. +- **Files**: new `utils/action_journal/capture.py`, executor dispatch, parallel and DAG binding, typed package exports, tests, three READMEs, Sphinx references, changelog and architecture maps. +- **Open items**: facade/command/CLI/MCP/GUI surfaces, run-history linking and legacy application-log redaction remain in `Progress.md`; B1 is not complete yet. diff --git a/docs/updates/README.md b/docs/updates/README.md index 972ae97a3..ad9ad6711 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,8 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-12 | 2026-10-18 | Capture executor action lifecycles with worker parent links | #done #journal #executor | [2026-10](2026-10.md) | +| U-20261018-11 | 2026-10-18 | Preserve foreign files when recovering journal tails | #incident #journal #storage | [2026-10](2026-10.md) | | U-20261018-10 | 2026-10-18 | Build versioned, redacted and durable action-journal storage | #done #journal #storage | [2026-10](2026-10.md) | | U-20261018-09 | 2026-10-18 | Separate sensitive MCP reads from viewer observation | #done #mcp #rbac | [2026-10](2026-10.md) | | U-20261018-08 | 2026-10-18 | Present REST RBAC mode and preserve legacy gate compatibility | #done #rest #rbac | [2026-10](2026-10.md) | @@ -373,7 +375,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 37 | +| [2026-10.md](2026-10.md) | 2026-10 | 39 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/action_journal/__init__.py b/je_auto_control/utils/action_journal/__init__.py index c52d8b8cd..91f969a79 100644 --- a/je_auto_control/utils/action_journal/__init__.py +++ b/je_auto_control/utils/action_journal/__init__.py @@ -1,5 +1,9 @@ -"""Versioned structured-action journal storage (capture integration is separate).""" +"""Versioned storage and explicitly scoped structured-action capture.""" from je_auto_control.utils.action_journal.events import ActionEvent, ActionJournalError from je_auto_control.utils.action_journal.store import ActionJournal, read_events +from je_auto_control.utils.action_journal.capture import ( + JournalBinding, JournalRunResult, execute_action_with_journal, journal_scope, +) -__all__ = ["ActionEvent", "ActionJournal", "ActionJournalError", "read_events"] +__all__ = ["ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunResult", + "read_events", "execute_action_with_journal", "journal_scope"] diff --git a/je_auto_control/utils/action_journal/capture.py b/je_auto_control/utils/action_journal/capture.py new file mode 100644 index 000000000..0d735ae87 --- /dev/null +++ b/je_auto_control/utils/action_journal/capture.py @@ -0,0 +1,172 @@ +"""Explicit run capture and parent propagation at executor action boundaries.""" +from __future__ import annotations + +import time +from asyncio import CancelledError +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Iterator, Mapping, Optional, Sequence, TypedDict, cast +from uuid import uuid4 + +from je_auto_control.utils.action_journal.events import ActionEvent, ActionJournalError, EventStatus +from je_auto_control.utils.action_journal.redaction import sanitize_event +from je_auto_control.utils.action_journal.store import ActionJournal +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.path_guard.path_guard import validate_path +from je_auto_control.utils.script_vars.execution import execution_scope + + +@dataclass(frozen=True) +class JournalBinding: + """An immutable run/parent binding that workers may explicitly inherit.""" + + journal: ActionJournal + run_id: str + parent_id: Optional[str] = None + device_id: Optional[str] = None + session_id: Optional[str] = None + + +class JournalRunResult(TypedDict): + """The captured run's identity, location and original execution outcomes.""" + + run_id: str + journal_path: str + record: dict[str, object] + failed_actions: int + + +@dataclass +class _CapturedStep: + outcome: object = None + + +_CURRENT: ContextVar[Optional[JournalBinding]] = ContextVar("je_auto_control_journal", default=None) + + +def current_journal() -> Optional[JournalBinding]: + """Return this context's active run/parent binding, without opening a log.""" + return _CURRENT.get() + + +@contextmanager +def bound_journal(binding: Optional[JournalBinding]) -> Iterator[None]: + """Bind a captured run in a worker; ``None`` clears ambient capture.""" + token = _CURRENT.set(binding) + try: + yield + finally: + _CURRENT.reset(token) + + +@contextmanager +def journal_scope(journal: ActionJournal, *, run_id: Optional[str] = None, + device_id: Optional[str] = None, session_id: Optional[str] = None) -> Iterator[JournalBinding]: + """Capture enclosed executor calls in one explicit run, restoring any outer run. + + Worker helpers carry the binding explicitly. A new scope starts a new root; + nested action bodies share the current binding until their call returns. + """ + identity = uuid4().hex if run_id is None else run_id + if not isinstance(identity, str) or not identity: + raise ActionJournalError("journal run ID must be a non-empty string") + binding = JournalBinding(journal, identity, device_id=device_id, session_id=session_id) + with bound_journal(binding): + yield binding + + +def _snapshot(action: list[object], binding: JournalBinding) -> ActionEvent: + command = action[0] if action and isinstance(action[0], str) else "" + parameters = action[1] if len(action) == 2 and isinstance(action[1], dict) else {"args": action[1:]} + event = ActionEvent(run_id=binding.run_id, step_id=uuid4().hex, command=command, + parameters=parameters, started_at=time.time(), parent_id=binding.parent_id, + device_id=binding.device_id, session_id=binding.session_id) + event.validate() + payload = sanitize_event(event) + # A finite JSON copy freezes inputs before dispatch; no user payload deepcopy + # or repr is called, and a command cannot rewrite its source snapshot later. + return replace(event, parameters=cast(dict[str, object], payload["parameters"]), + non_replayable=tuple(cast(list[str], payload["non_replayable"]))) + + +def _failure_count() -> int: + # pylint: disable-next=import-outside-toplevel # reason: breaks the capture/executor import cycle + from je_auto_control.utils.executor.action_executor import recorded_failures + return recorded_failures() + + +def _exception_status(error: BaseException) -> EventStatus: + # pylint: disable-next=import-outside-toplevel # reason: flow_control imports this capture adapter + from je_auto_control.utils.executor.flow_control import LoopBreak, LoopContinue + # pylint: disable-next=import-outside-toplevel # reason: MCP imports the executor during facade setup + from je_auto_control.utils.mcp_server.context import OperationCancelledError + if isinstance(error, (LoopBreak, LoopContinue)): + return "ok" # the enclosing loop consumes the signal + if isinstance(error, (KeyboardInterrupt, SystemExit, GeneratorExit, CancelledError, OperationCancelledError)): + return "cancelled" + return "error" + + +def _completion(event: ActionEvent, step: _CapturedStep, before: int, + error: Optional[BaseException], dry_run: bool) -> ActionEvent: + status: EventStatus = "dry_run" if dry_run else "ok" + if error is not None: + status = _exception_status(error) + elif _failure_count() > before: + status = "error" + reasons = event.non_replayable + (("not_executed",) if dry_run else ()) + return replace(event, finished_at=max(event.started_at, time.time()), status=status, + outcome=step.outcome, error="action did not complete" if status in {"error", "cancelled"} else None, + non_replayable=reasons) + + +@contextmanager +def action_step(action: list[object], *, dry_run: bool = False) -> Iterator[Optional[_CapturedStep]]: + """Capture one attempted action; without a binding, leave execution untouched.""" + binding = current_journal() + if binding is None: + yield None + return + event = _snapshot(action, binding) + binding.journal.append(event) # durable start precedes every device effect + step, before = _CapturedStep(), _failure_count() + error: Optional[BaseException] = None + with bound_journal(replace(binding, parent_id=event.step_id)): + try: + yield step + except BaseException as failure: # reason: settle capture, then propagate the original control flow + error = failure + raise + finally: + try: + binding.journal.append(_completion(event, step, before, error, dry_run)) + except ActionJournalError: + if error is None: + raise + # Preserve a cancellation or original failure. Its durable start + # stays incomplete if completion could not be written. + autocontrol_logger.error("action journal completion unavailable; preserving original exception") + + +def execute_action_with_journal(action_list: Sequence[object] | Mapping[str, object], path: Path, *, + run_id: Optional[str] = None, + variables: Optional[Mapping[str, object]] = None, dry_run: bool = False, + device_id: Optional[str] = None, session_id: Optional[str] = None) -> JournalRunResult: + """Run actions with fresh variables and explicit journal capture on the module executor. + + Returns the run ID, canonical journal path, original executor record and this + run's failure-count delta. Direct private executors can use ``journal_scope`` + to capture their existing scope without changing ownership of their variables. + """ + # pylint: disable-next=import-outside-toplevel # reason: breaks the capture/executor import cycle + from je_auto_control.utils.executor.action_executor import executor + journal = ActionJournal(validate_path(path)) + with execution_scope(variables), journal_scope(journal, run_id=run_id, + device_id=device_id, session_id=session_id) as binding: + before = _failure_count() + actions = dict(action_list) if isinstance(action_list, Mapping) else list(action_list) + record: dict[str, object] = dict(executor.execute_action(actions, dry_run=dry_run)) + return {"run_id": binding.run_id, "journal_path": str(journal.path), "record": record, + "failed_actions": _failure_count() - before} diff --git a/je_auto_control/utils/action_journal/store.py b/je_auto_control/utils/action_journal/store.py index 36a5f6ed7..54a5a3f28 100644 --- a/je_auto_control/utils/action_journal/store.py +++ b/je_auto_control/utils/action_journal/store.py @@ -44,6 +44,8 @@ def append(self, event: ActionEvent) -> None: append_json_line(self.path, _encode(payload)) with self.path.open("ab") as handle: os.fsync(handle.fileno()) + except ActionJournalError: + raise except (OSError, ValueError) as error: raise ActionJournalError("could not append action journal") from error @@ -60,23 +62,39 @@ def _encode(payload: dict[str, object]) -> str: def _next_sequence(path: Path) -> int: - """Read only the tail, discarding an uncommitted fragment under the lock.""" + """Verify journal identity and its committed tail before repairing anything.""" fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600) with os.fdopen(fd, "a+b") as handle: offset, tail = _tail(handle) - if tail and not tail.endswith(b"\n"): - boundary = tail.rfind(b"\n") + 1 - handle.truncate(offset + boundary) - tail = tail[:boundary] if not tail: return 1 - raw = tail[:-1].rsplit(b"\n", 1)[-1] - sequence = _decode(raw, 0).sequence + 1 + first, head_end = _journal_head(handle) + boundary = len(tail) if tail.endswith(b"\n") else tail.rfind(b"\n") + 1 + committed = tail[:boundary] + if not committed: + raise ActionJournalError("destination has no committed journal record") + last = _decode(committed[:-1].rsplit(b"\n", 1)[-1], 0) + if last.sequence < first.sequence or (last.sequence == first.sequence and offset + boundary > head_end): + raise ActionJournalError("unordered action journal tail") + sequence = last.sequence + 1 if sequence > 2 ** 63 - 1: raise ActionJournalError("action journal sequence exhausted") + # Reaching here proved both the file's format and the preceding record. + # A mistaken destination or corrupt committed line must remain intact. + if boundary != len(tail): + handle.truncate(offset + boundary) return sequence +def _journal_head(handle: BinaryIO) -> tuple[ActionEvent, int]: + """Refuse foreign files and ambiguous torn first records without modifying them.""" + handle.seek(0) + line = handle.readline(_MAX_RECORD_BYTES + 2) + if len(line) > _MAX_RECORD_BYTES + 1 or not line.endswith(b"\n"): + raise ActionJournalError("destination has no committed journal header") + return _decode(line, 1), handle.tell() + + def _tail(handle: BinaryIO) -> tuple[int, bytes]: position = handle.seek(0, os.SEEK_END) chunks: list[bytes] = [] @@ -107,8 +125,9 @@ def _decode(raw: bytes, line_number: int) -> ActionEvent: def read_events(path: Path, *, run_id: Optional[str] = None) -> list[ActionEvent]: """Read validated snapshots in append order, optionally selecting one run. - A final unterminated fragment is uncommitted and ignored. Malformed complete - lines, unsupported schemas and non-increasing sequences are rejected. Start + A final unterminated fragment after a valid record is uncommitted and ignored. + Ambiguous first fragments, malformed complete lines, unsupported schemas and + non-increasing sequences are rejected. Start snapshots remain incomplete; reading never infers successful completion. """ try: @@ -131,6 +150,8 @@ def _snapshots(handle: BinaryIO) -> Iterator[ActionEvent]: if len(line) > _MAX_RECORD_BYTES + 1: raise ActionJournalError("action journal record exceeds 8 MiB") if not line.endswith(b"\n"): + if line and index == 0: + raise ActionJournalError("destination has no committed journal record") return # EOF or the uncommitted final fragment index += 1 event = _decode(line, index) diff --git a/je_auto_control/utils/dag/runner.py b/je_auto_control/utils/dag/runner.py index 1a53092a9..6f7abbaf8 100644 --- a/je_auto_control/utils/dag/runner.py +++ b/je_auto_control/utils/dag/runner.py @@ -302,11 +302,13 @@ def _current_run_local_runner() -> NodeRunner: """ from je_auto_control.utils.executor.action_executor import _running_executor from je_auto_control.utils.script_vars.execution import bound_scope + from je_auto_control.utils.action_journal.capture import bound_journal, current_journal runner = _running_executor() scope = runner.variables + journal = current_journal() def run(node: DagNode, definition: DagDefinition) -> Any: - with bound_scope(scope): + with bound_scope(scope), bound_journal(journal): return _default_local_runner(node, definition, runner) return run diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index cc475cf85..3deb26f4c 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -33,6 +33,7 @@ BLOCK_COMMANDS, LoopBreak, LoopContinue, MacroDepthExceeded, ) from je_auto_control.utils.executor.action_redaction import describe_action, redact_actions +from je_auto_control.utils.action_journal.capture import action_step from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS from je_auto_control.utils.llm.planner import ( plan_actions as llm_plan_actions, @@ -8227,25 +8228,32 @@ def _execute_list(self, action_list: Union[list, dict], raise_on_error: bool, execute_record_dict: Dict[str, Any] = {} for action in action_list: - if step_callback is not None: - step_callback(action) - if dry_run: - key = _unique_key(execute_record_dict, "dry-run: " + describe_action(action)) - execute_record_dict[key] = "(not executed)" - continue - key = _unique_key(execute_record_dict, "execute: " + describe_action(action)) - try: - self._run_one_action(action, execute_record_dict, raise_on_error, key) - except (LoopBreak, LoopContinue, MacroDepthExceeded) as signal: - if _validated: - raise # a nested body: the enclosing block handles it - self._record_unwound_signal( - signal, execute_record_dict, raise_on_error, key) + self._execute_list_step(action, execute_record_dict, raise_on_error, _validated, dry_run, step_callback) for key, value in execute_record_dict.items(): autocontrol_logger.info("%s -> %s", key, value) return execute_record_dict + def _execute_list_step(self, action: list, record: Dict[str, Any], raise_on_error: bool, + validated: bool, dry_run: bool, callback: Optional[Callable[[list], None]]) -> None: + """Dispatch one step inside its optional journal capture boundary.""" + if callback is not None: + callback(action) + with action_step(action, dry_run=dry_run) as capture: + if dry_run: + key = _unique_key(record, "dry-run: " + describe_action(action)) + record[key] = "(not executed)" + return + key = _unique_key(record, "execute: " + describe_action(action)) + try: + self._run_one_action(action, record, raise_on_error, key) + except (LoopBreak, LoopContinue, MacroDepthExceeded) as signal: + if validated: + raise # a nested body: the enclosing block handles it + self._record_unwound_signal(signal, record, raise_on_error, key) + if capture is not None: + capture.outcome = record.get(key) + @staticmethod def _record_unwound_signal(signal: Exception, record: Dict[str, Any], raise_on_error: bool, key: str) -> None: diff --git a/je_auto_control/utils/executor/flow_control.py b/je_auto_control/utils/executor/flow_control.py index 91c0a5233..bd3e164a5 100644 --- a/je_auto_control/utils/executor/flow_control.py +++ b/je_auto_control/utils/executor/flow_control.py @@ -23,6 +23,7 @@ ) from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.rbac.authorization import authorization_scope, current_authorization +from je_auto_control.utils.action_journal.capture import bound_journal, current_journal from je_auto_control.wrapper.auto_control_image import locate_image_center from je_auto_control.wrapper.auto_control_screen import get_pixel from je_auto_control.utils.timeouts import deadline_after @@ -469,6 +470,7 @@ def __init__(self, executor: Any, branches: list) -> None: # The RBAC user is bound to the thread that accepted the request; a # branch thread without it ran privileged commands unchecked. self._caller = current_authorization() + self._journal = current_journal() self.results: list = [None] * len(branches) self._failures: list = [0] * len(branches) self._errors: Dict[int, str] = {} @@ -498,7 +500,7 @@ def run_branch(self, index: int, branch: Any) -> None: _MACRO_DEPTH.value = self._macro_depth self._module.reset_recorded_failures() try: - with authorization_scope(self._caller): + with authorization_scope(self._caller), bound_journal(self._journal): self.results[index] = self._branch_executor().execute_action( branch, raise_on_error=self._strict, _validated=True) self._failures[index] = self._module.recorded_failures() diff --git a/test/unit_test/headless/test_action_journal.py b/test/unit_test/headless/test_action_journal.py index 9e448fbe5..e4b56e509 100644 --- a/test/unit_test/headless/test_action_journal.py +++ b/test/unit_test/headless/test_action_journal.py @@ -285,3 +285,38 @@ def test_storage_errors_stay_in_the_framework_exception_family(tmp_path, operati read_events(destination) else: ActionJournal(destination).append(_event()) + + +@pytest.mark.parametrize("existing", [ + b"unrelated user content", + b'{"users":[{"name":"local"}]}', + b'{\n "users": [\n "local"\n ]\n}', + b'ordinary text\npartial tail', + b'{"schema_version":1,"run_id":"partial-first-record"', +]) +def test_append_never_truncates_an_existing_nonjournal_destination(tmp_path, existing): + journal = ActionJournal(tmp_path / "existing.jsonl") + journal.path.write_bytes(existing) + with pytest.raises(ActionJournalError): + journal.append(_event()) + assert journal.path.read_bytes() == existing + with pytest.raises(ActionJournalError): + read_events(journal.path) + + +def test_corrupt_complete_record_prevents_torn_tail_repair(tmp_path): + journal = ActionJournal(tmp_path / "events.jsonl") + journal.append(_event()) + with journal.path.open("ab") as handle: + handle.write(b'{not-json}\npartial') + original = journal.path.read_bytes() + with pytest.raises(ActionJournalError): + journal.append(_event()) + assert journal.path.read_bytes() == original + + +def test_foreign_destination_reports_the_format_refusal(tmp_path): + journal = ActionJournal(tmp_path / "existing.jsonl") + journal.path.write_bytes(b"unrelated user content") + with pytest.raises(ActionJournalError, match="destination has no committed journal"): + journal.append(_event()) diff --git a/test/unit_test/headless/test_action_journal_capture.py b/test/unit_test/headless/test_action_journal_capture.py new file mode 100644 index 000000000..6797a25ce --- /dev/null +++ b/test/unit_test/headless/test_action_journal_capture.py @@ -0,0 +1,196 @@ +"""Executor capture preserves real outcomes, parent links and run isolation.""" +import subprocess +import sys + +import pytest + +from je_auto_control.utils.action_journal import ActionJournal, execute_action_with_journal, journal_scope, read_events +from je_auto_control.utils.action_journal.capture import current_journal +from je_auto_control.utils.action_journal.events import ActionJournalError +from je_auto_control.utils.exception.exceptions import AutoControlActionException +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.script_vars.execution import execution_scope + +PROBE = "AC_journal_probe" + + +@pytest.fixture +def seen(monkeypatch): + values = [] + monkeypatch.setitem(executor.event_dict, PROBE, lambda value: values.append(value) or value) + return values + + +def _completed(path): + return [event for event in read_events(path) if event.status != "incomplete"] + + +def test_scoped_capture_preserves_source_refs_and_never_writes_resolved_secrets(tmp_path, seen, monkeypatch): + from je_auto_control.utils.secrets import default_secret_manager + monkeypatch.setattr(default_secret_manager, "get", lambda name: "resolved-private-value") + path = tmp_path / "events.jsonl" + result = execute_action_with_journal([[PROBE, {"value": "${secrets.LOGIN}"}]], path, run_id="run") + assert result["run_id"] == "run" and result["failed_actions"] == 0 + assert seen == ["resolved-private-value"] + assert "resolved-private-value" not in path.read_text(encoding="utf-8") + events = read_events(path) + assert [event.status for event in events] == ["incomplete", "ok"] + assert all(event.parameters == {"value": "${secrets.LOGIN}"} for event in events) + assert events[-1].outcome_type == "text" + assert current_journal() is None + + +def test_loop_children_share_run_and_point_to_the_observed_parent(tmp_path, seen): + path = tmp_path / "events.jsonl" + execute_action_with_journal([["AC_loop", {"times": 2, "body": [[PROBE, {"value": "child"}]]}]], + path, run_id="loop") + completed = _completed(path) + parent = next(event for event in completed if event.command == "AC_loop") + children = [event for event in completed if event.command == PROBE] + assert len(children) == 2 and len({event.step_id for event in children}) == 2 + assert all(event.run_id == "loop" and event.parent_id == parent.step_id for event in children) + assert parent.parent_id is None and parent.status == "ok" and seen == ["child", "child"] + assert all(event.sequence < parent.sequence for event in children) + + +@pytest.mark.parametrize("block", ["parallel", "dag"]) +def test_worker_actions_retain_parent_and_identity(tmp_path, seen, block): + path = tmp_path / "events.jsonl" + actions = [[[PROBE, {"value": value}]] for value in ("left", "right")] + if block == "parallel": + parent_command = "AC_parallel" + script = [[parent_command, {"branches": actions}]] + else: + parent_command = "AC_run_dag" + script = [[parent_command, {"definition": {"nodes": [ + {"id": str(index), "actions": body} for index, body in enumerate(actions)]}}]] + execute_action_with_journal(script, path, run_id="workers", device_id="device", session_id="session") + completed = _completed(path) + parent = next(event for event in completed if event.command == parent_command) + children = [event for event in completed if event.command == PROBE] + assert sorted(seen) == ["left", "right"] and len(children) == 2 + assert all(event.parent_id == parent.step_id for event in children) + assert all((event.run_id, event.device_id, event.session_id) == ("workers", "device", "session") + for event in read_events(path)) + + +@pytest.mark.parametrize("strict", [False, True]) +def test_failed_dispatch_is_never_reported_as_success(tmp_path, monkeypatch, strict): + def fail(): + raise AutoControlActionException("private exception payload") + + monkeypatch.setitem(executor.event_dict, "AC_journal_fail", fail) + path = tmp_path / "events.jsonl" + with journal_scope(ActionJournal(path), run_id="failure"): + if strict: + with pytest.raises(AutoControlActionException): + executor.execute_action([["AC_journal_fail"]], raise_on_error=True) + else: + executor.execute_action([["AC_journal_fail"]]) + assert _completed(path)[0].status == "error" + assert "private exception payload" not in path.read_text(encoding="utf-8") + assert current_journal() is None + + +@pytest.mark.parametrize("nested,status", [(False, "error"), (True, "ok")]) +def test_break_records_its_actual_control_flow_outcome(tmp_path, nested, status): + path = tmp_path / "events.jsonl" + actions = [["AC_break"]] + if nested: + actions = [["AC_loop", {"times": 2, "body": actions}]] + execute_action_with_journal(actions, path) + assert all(event.status == status for event in _completed(path)) + + +def test_cancellation_is_recorded_and_restores_context(tmp_path, monkeypatch): + def cancel(): + raise KeyboardInterrupt() + + monkeypatch.setitem(executor.event_dict, "AC_journal_cancel", cancel) + path = tmp_path / "events.jsonl" + with pytest.raises(KeyboardInterrupt): + execute_action_with_journal([["AC_journal_cancel"]], path) + assert _completed(path)[0].status == "cancelled" + assert current_journal() is None + + +def test_dry_run_is_labelled_and_invokes_no_handler(tmp_path, seen): + path = tmp_path / "events.jsonl" + execute_action_with_journal([[PROBE, {"value": "never"}]], path, dry_run=True) + event = _completed(path)[0] + assert event.status == "dry_run" and "not_executed" in event.non_replayable + assert seen == [] + + +def test_nested_scopes_restore_parent_and_isolate_seeded_variables(tmp_path, seen): + outer, inner = tmp_path / "outer.jsonl", tmp_path / "inner.jsonl" + with execution_scope({"owner": "outer"}): + with journal_scope(ActionJournal(outer), run_id="outer") as binding: + execute_action_with_journal([[PROBE, {"value": "${owner}"}]], inner, + variables={"owner": "inner"}, run_id="inner") + assert current_journal() is binding + executor.execute_action([[PROBE, {"value": "${owner}"}]]) + assert seen == ["inner", "outer"] and current_journal() is None + assert {event.run_id for event in read_events(outer)} == {"outer"} + assert {event.run_id for event in read_events(inner)} == {"inner"} + + +def test_plain_execution_keeps_its_existing_result_contract(tmp_path, seen): + record = executor.execute_action([[PROBE, {"value": "plain"}]]) + assert list(record.values()) == ["plain"] and seen == ["plain"] + assert list(tmp_path.iterdir()) == [] and current_journal() is None + + +@pytest.mark.parametrize("strict", [False, True]) +def test_completion_storage_failure_keeps_the_durable_start_incomplete(tmp_path, monkeypatch, strict): + def fail(): + raise AutoControlActionException("original failure") + + monkeypatch.setitem(executor.event_dict, "AC_journal_fail", fail) + journal = ActionJournal(tmp_path / "events.jsonl") + append = journal.append + calls = [] + + def interrupted(event): + calls.append(event.status) + if len(calls) > 1: + raise ActionJournalError("storage unavailable") + append(event) + + monkeypatch.setattr(journal, "append", interrupted) + with pytest.raises(AutoControlActionException if strict else ActionJournalError): + with journal_scope(journal): + executor.execute_action([["AC_journal_fail"]], raise_on_error=strict) + assert [event.status for event in read_events(journal.path)] == ["incomplete"] + assert current_journal() is None + + +def test_pre_dispatch_callback_cancellation_does_not_invent_an_attempt(tmp_path, seen): + path = tmp_path / "events.jsonl" + + def stopped(_action): + raise KeyboardInterrupt() + + with pytest.raises(KeyboardInterrupt): + with journal_scope(ActionJournal(path)): + executor.execute_action([[PROBE, {"value": "never"}]], step_callback=stopped) + assert not path.exists() and seen == [] and current_journal() is None + + +def test_process_exit_during_a_command_preserves_only_its_incomplete_start(tmp_path): + path = tmp_path / "crash.jsonl" + script = ''' +import os, sys +from pathlib import Path +from je_auto_control.utils.action_journal import ActionJournal, journal_scope +from je_auto_control.utils.executor.action_executor import executor +executor.event_dict["AC_journal_crash"] = lambda: os._exit(23) +with journal_scope(ActionJournal(Path(sys.argv[1])), run_id="crashed"): + executor.execute_action([["AC_journal_crash"]]) +''' + done = subprocess.run( # nosec B603 # nosemgrep # reason: own fixed helper, only a temp path is passed as data + [sys.executable, "-c", script, str(path)], capture_output=True, text=True, timeout=60, check=False, + ) + assert done.returncode == 23, done.stderr + events = read_events(path) + assert len(events) == 1 and events[0].run_id == "crashed" and events[0].status == "incomplete" From 132198d0c7b7cce2d0e9dcefbfa5fd1d04bdd036 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 22:29:57 +0800 Subject: [PATCH 012/294] Keep resolved vault values out of executor application logs --- CHANGELOG.md | 5 + Progress.md | 2 +- README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture.md | 5 + architecture_explore.md | 28 +-- .../Eng/doc/new_features/new_features_doc.rst | 13 +- .../Zh/doc/new_features/new_features_doc.rst | 11 +- docs/updates/2026-10.md | 10 +- docs/updates/README.md | 3 +- je_auto_control/utils/dag/runner.py | 4 +- .../utils/executor/action_executor.py | 8 +- .../utils/executor/flow_control.py | 10 +- .../utils/logging/logging_instance.py | 3 + je_auto_control/utils/logging/secret_scope.py | 140 ++++++++++++++ .../utils/script_vars/interpolate.py | 3 + .../headless/test_runtime_secret_logs.py | 182 ++++++++++++++++++ 18 files changed, 405 insertions(+), 28 deletions(-) create mode 100644 je_auto_control/utils/logging/secret_scope.py create mode 100644 test/unit_test/headless/test_runtime_secret_logs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f96511dd..23c2582cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Executor runs mask resolved vault-reference values in application log messages, + exception text and tracebacks before handlers receive them. Nested actions and + joined local parallel/DAG workers share a run-local mask, cleared on exit. + Returned results and raised exceptions retain their original values. + - Journal tail recovery validates the destination's format and last committed event before truncation. Existing non-journal files, corrupt committed tails diff --git a/Progress.md b/Progress.md index 2c15da623..b2218134c 100644 --- a/Progress.md +++ b/Progress.md @@ -222,7 +222,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 -- **既有應用程式日誌的秘密遮罩**:`utils/executor/action_executor.py` 的結果與例外記錄仍直接格式化 outcome/`repr(error)`;需在該邊界遮罩 `${secrets.*}` 的解析後值,避免寫入應用程式日誌。 +- **executor 外層的秘密遮罩**:`__main__.py`、`utils/{rest_api,mcp_server}/` 的外層 catch 仍在 executor 遮罩結束後格式化原始例外;需將遮罩生命週期延伸到入口的錯誤日誌,並檢查 audit/run-history 的持久化邊界,保留 API 回傳契約。 - **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index 0bdfc863f..4e3f6acce 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,8 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Facade, command, CLI/MCP and GUI controls remain pending integration. +During executor runs, values resolved from `${secrets.NAME}` are masked in `AutoControlGUI` application messages and tracebacks, including nested actions and local parallel/DAG workers. Masks are discarded at run exit; returned outcomes and raised exceptions keep their original values. + ## Command-line interface Each `je_auto_control run` invocation, including `--dry-run` and runs without `--var`, gets a fresh variable scope. Top-level `run_from_description` and `StateMachine.run()` also isolate variables while sharing them across their own actions and states; nested calls reuse an existing execution scope. A caller-owned `Executor()` keeps its own variables. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 823e33496..08fd2b5a0 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -247,6 +247,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。门面、动作命令、CLI/MCP 与 GUI 控制仍待接入。 +executor 执行期间,`${secrets.NAME}` 解析后的值会在 `AutoControlGUI` 应用程序消息与 traceback 中遮罩,包括嵌套动作及本机 parallel/DAG 工作线程。遮罩数据于执行结束时清除;返回结果与抛出的异常保留原始值。 + ## 命令行界面 每次 `je_auto_control run`(包括 `--dry-run` 与未带 `--var`)使用独立变量作用域。顶层 `run_from_description` 与 `StateMachine.run()` 也会隔离变量,同一次执行内的动作与状态仍可共享;嵌套调用沿用已有的执行作用域。自行创建的 `Executor()` 保留自己的变量。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 46417c6e6..c91df252a 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -246,6 +246,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。門面、動作指令、CLI/MCP 與 GUI 控制仍待接入。 +executor 執行期間,`${secrets.NAME}` 解析後的值會在 `AutoControlGUI` 應用程式訊息與 traceback 中遮罩,包含巢狀動作及本機 parallel/DAG 工作執行緒。遮罩資料於執行結束時清除;回傳結果與拋出的例外保留原始值。 + ## 命令列介面 每次 `je_auto_control run`(包括 `--dry-run` 與未帶 `--var`)使用獨立變數範圍。頂層 `run_from_description` 與 `StateMachine.run()` 也會隔離變數,同一次執行內的動作與狀態仍可共享;巢狀呼叫沿用已有的執行範圍。自行建立的 `Executor()` 保留自己的變數。 diff --git a/architecture.md b/architecture.md index 5797ac9a5..04fcf1120 100644 --- a/architecture.md +++ b/architecture.md @@ -57,6 +57,11 @@ entry points → execution core (`utils/executor/`) → headless capabilities (` ## 4. Main flows +Executor runs own a `utils/logging/secret_scope.py` mask for vault values resolved +by interpolation. A logger filter sanitizes application records before handlers; +joined local parallel/DAG workers share the mask, independent runs isolate it, +and the owning run clears it on exit. Returned outcomes retain their values. + **A. JSON action script (primary path)** ``` diff --git a/architecture_explore.md b/architecture_explore.md index 049546973..141ea34ca 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,8 +19,8 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,085 | -| 程式碼總行數 | 162,653 | +| Python 模組總數(含周邊子專案) | 1,086 | +| 程式碼總行數 | 162,807 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,831 行。 +> 25 個套件、約 15,842 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -280,11 +280,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/action_signing/` | 716 | action 檔 HMAC-SHA256 簽章與 Fernet 加密,`execute_files` 會強制驗簽 | | `utils/checkpoint/` | 129 | 流程檢查點與續跑,讓長 action list 具持久性 | | `utils/codegen/` | 294 | 由 action list 產生可執行的 pytest / python / robot 測試碼 | -| `utils/dag/` | 562 | 跨主機 DAG 編排器(圖模型 + runner) | +| `utils/dag/` | 564 | 跨主機 DAG 編排器(圖模型 + runner) | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | | `utils/action_journal/` | 585 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | -| `utils/executor/` | 9,676 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/executor/` | 9,682 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -295,7 +295,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/project/` | 183 | 專案腳手架:建立目錄結構與範本 action 檔 | | `utils/recording_edit/` | 165 | 不重錄的前提下裁切/過濾/縮放已錄製的 action list | | `utils/saga/` | 103 | Saga 協調器:失敗時以 LIFO 補償動作回滾 | -| `utils/script_vars/` | 288 | 執行期變數作用域與 `${var}` / `${secrets.*}` 插值 | +| `utils/script_vars/` | 291 | 執行期變數作用域與 `${var}` / `${secrets.*}` 插值 | | `utils/skill_library/` | 145 | 具名可重用 action 序列(skill)的持久化倉庫 | | `utils/state_machine/` | 277 | 宣告式有限狀態機驅動 action JSON;預設動作跨狀態共享單次執行範圍 | | `utils/stubs/` | 311 | 為 `AC_*` 指令面產生型別 stub | @@ -304,7 +304,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.2 框架基礎設施 -> 14 個套件、約 3,256 行。 +> 14 個套件、約 3,399 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -316,7 +316,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/exception/` | 223 | **例外階層根**。所有錯誤繼承 `AutoControlException`,加上集中式錯誤訊息字串(`exception_tags`) | | `utils/failure_bundle/` | 229 | 可攜、已遮蔽的失敗診斷 ZIP(截圖 + 診斷 + log 尾段) | | `utils/file_process/` | 40 | 目錄檔案列舉(`execute_dir` 的後端) | -| `utils/logging/` | 168 | `autocontrol_logger` 單例 + 家目錄共用記錄檔 handler(`JE_AUTOCONTROL_LOG_FILE` 可改) | +| `utils/logging/` | 311 | `autocontrol_logger`、家目錄共用 handler 與 executor 執行期間的 vault 值遮罩(含本機 parallel/DAG) | | `utils/package_manager/` | 179 | 動態載入套件並把 executor 注入其中;載入前先過套件閘門(允許清單加上只能從 Python 呼叫的開關,工作區 X-12) | | `utils/path_guard/` | 231 | 命令列傳入路徑的正規化與邊界檢查(防路徑穿越) | | `utils/platform_id/` | 62 | 作業系統家族的單一判定點。`sys.platform` 原本在一百多處跟字面清單比對,而那些清單都沒有 BSD;`is_x11_unix()` 問的是「這是不是 X11 unix」,這才是守衛一直想問的問題 | @@ -697,12 +697,12 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,676 行)— 執行核心 +#### `utils/executor/`(9,682 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,443 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | -| `flow_control.py` | 658 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | +| `action_executor.py` | 8,445 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `flow_control.py` | 662 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | | `action_schema.py` | 159 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | | `action_redaction.py` | 86 | 記錄與紀錄鍵用的遮蔽:`AC_secret_*` 的參數(金庫通行碼、機密值)在寫進 log、當成結果紀錄的鍵之前換成 `***`,巢狀在區塊指令裡的也一樣。 | @@ -1085,7 +1085,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `gui/` | 100 | 28,788 | | `utils/mcp_server/` | 37 | 19,164 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,676 | +| `utils/executor/` | 8 | 9,682 | | `utils/usb/` | 17 | 4,816 | | `je_auto_control/`(頂層 3 檔) | 3 | 2,459 | | `utils/accessibility/` | 14 | 3,143 | @@ -1102,6 +1102,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 695 | 59,315 | -| **總計** | **1,079** | **162,588** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 696 | 59,463 | +| **總計** | **1,080** | **162,742** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 7ac72d47b..aef15d6cf 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1338,8 +1338,7 @@ Action-JSON commands:: Action scripts reference vault entries through ``${secrets.NAME}`` placeholders. The interpolator routes the ``secrets.`` namespace to the -vault rather than the regular variable scope, so plaintext values never -land in the variable bag:: +vault directly rather than looking up an ordinary variable:: [["AC_shell_command", {"command": "curl -H \"Authorization: Bearer ${secrets.github_token}\" ..."}]] @@ -1348,6 +1347,16 @@ land in the variable bag:: so a filled-in secret stays out of the log; a program that cannot start fails the action. +During executor execution, resolved ``${secrets.NAME}`` values are replaced +with ``***`` in ``AutoControlGUI`` application messages, exception text and +stack information before handlers receive them. Exact values and common +Python/JSON escaped forms are masked. Nested actions and joined local +parallel/DAG workers share the run's mask; concurrent independent runs have +separate masks, and run exit clears the values even on failure. API outcomes +and raised exceptions retain their original values. This masking covers +values learned during that executor run; transformed/encoded values and +other applications' loggers require their own handling. + GUI: **Secrets** tab — initialize the vault, unlock it, add / remove entries, change passphrase. The vault file is created with mode 0o600 on POSIX systems; on Windows the default ACL already restricts diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index bb27c9593..006f18de5 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1244,8 +1244,7 @@ Action JSON 指令:: [["AC_secret_status"]] 腳本透過 ``${secrets.NAME}`` 佔位符引用 vault 條目。插補器會把 -``secrets.`` 命名空間導向 vault,而不是普通變數作用域,所以明文值 -永遠不會進到變數袋裡:: +``secrets.`` 命名空間直接導向 vault,而不查詢一般變數:: [["AC_shell_command", {"command": "curl -H \"Authorization: Bearer ${secrets.github_token}\" ..."}]] @@ -1253,6 +1252,14 @@ Action JSON 指令:: ``AC_shell_command`` 只記錄它啟動的程式,從不記錄參數,所以填入的 secret 不會進到 log; 程式無法啟動時該動作會失敗。 +executor 執行期間,解析後的 ``${secrets.NAME}`` 值會在送到 handler 前,於 +``AutoControlGUI`` 應用程式訊息、例外文字與堆疊資訊中替換為 ``***``。 +遮罩包含原始值與常見 Python/JSON 跳脫形式。巢狀動作及等候完成的本機 +parallel/DAG 工作執行緒共用當次執行的遮罩;各個獨立執行彼此隔離, +即使失敗也會於執行結束時清除資料。API 回傳結果與拋出的例外仍保留原始值。 +此遮罩涵蓋該次 executor 執行期間解析的值;經轉換/編碼的值,以及其他 +應用程式的 logger,仍由各自的邊界處理。 + GUI: **Secrets** 分頁 — 建立 vault、解鎖、新增 / 移除條目、變更 通行碼。POSIX 系統上 vault 檔以 0o600 建立;Windows 預設 ACL 已限制 只有擁有者能讀取。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 232bbaefe..5448ff9a2 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -345,7 +345,7 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **What**: CLI `run` always opens a fresh variable scope, including previews, empty seeds and failures. Top-level `run_from_description` and `StateMachine.run()` also isolate their script variables, while nested helpers reuse the enclosing run's scope. FSM states share run-local values; caller-owned executor variables and the FSM's separate context retain their ownership. - **Correctness**: the FSM's default bridge now distinguishes a standard single action from a nested action batch, so real `on_enter` commands execute rather than fail schema validation. Corrected variable-scope documentation about Python thread context inheritance. -- **Validation**: 15 new cases cover all CLI seed/preview combinations, repeated and concurrent runs, cross-state values, nested calls and restoration after errors; a 128-test related regression run and the subsequent 15-case focused run pass; 166 flow-control, documentation and cross-project checks also pass. Three-platform mypy reports zero modules with errors; strict English and Traditional Chinese Sphinx builds pass. +- **Validation**: before the final formatter-containment guard, the full coverage run passed 11,641 tests with 69 skipped and 87.63% coverage. 15 new cases cover all CLI seed/preview combinations, repeated and concurrent runs, cross-state values, nested calls and restoration after errors; a 128-test related regression run and the subsequent 15-case focused run pass; 166 flow-control, documentation and cross-project checks also pass. Three-platform mypy reports zero modules with errors; strict English and Traditional Chinese Sphinx builds pass. - **Files**: `cli.py`, `utils/llm/planner.py`, `utils/state_machine/engine.py`, variable-scope documentation, regression tests, three READMEs, Sphinx references, changelog and architecture maps. - **Open items**: native free-threaded concurrency verification remains in `Progress.md`. @@ -397,3 +397,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 16 new capture cases pass. Before the final tail safeguard, the full coverage run passed 11,620 tests with 69 skipped and 87.65% coverage. After the safeguard, the 157-test focused selection passes with 96.08% scoped journal coverage. Jeffrey_RPA's three facade/control suites pass 751 tests against this working tree (five existing Discord deprecations). New modules pass strict mypy; the full three-platform typing gate passes. The facade remains Qt-free. Another 41 documentation/budget/exception checks, strict Sphinx builds and wheel/source builds pass; pylint scores 10.00/10 using the repository limits. - **Files**: new `utils/action_journal/capture.py`, executor dispatch, parallel and DAG binding, typed package exports, tests, three READMEs, Sphinx references, changelog and architecture maps. - **Open items**: facade/command/CLI/MCP/GUI surfaces, run-history linking and legacy application-log redaction remain in `Progress.md`; B1 is not complete yet. + +## U-20261018-13 · 2026-10-18 · Mask resolved vault values during executor application logging · #done #security #logging + +- **What**: executor runs now own a thread-safe, context-local mask for resolved `${secrets.NAME}` values. The `AutoControlGUI` logger sanitizes formatted messages, exception text and stack information before handing records to any handler; exact values and common Python/JSON escaped forms are masked. Nested actions and joined local parallel/DAG workers share the run's mask, while independent concurrent runs isolate it. +- **Lifetime and compatibility**: masks are cleared on run exit, including failure, and closed bindings cannot retain new values. Literal matching avoids process-global regex caches. API outcomes and raised exceptions retain their original contents. This closes the legacy application-log gap recorded alongside B1 capture; structured-journal redaction remains separate. +- **Validation**: 15 new cases cover command logs, outcomes, strict/soft exceptions, tracebacks, nested and worker capture, parallel failure handling, escaping, concurrent isolation, cleanup and unformattable outcomes. The final focused journal/scope/flow and documentation/reason selection passes 191 tests, with 99.17% scoped mask coverage. Another 131 documentation, complexity, file-budget, exception, cross-project, light-import and log-handler checks pass. Strict mypy and the three-platform full typing gate pass; ruff and bandit pass, and pylint scores 10.00/10. Strict English/Chinese Sphinx and wheel/source builds pass; the wheel matches all six changed runtime sources. Jeffrey_RPA's three facade/control suites pass 751 tests against this working tree (five existing Discord deprecations). +- **Files**: new `utils/logging/secret_scope.py`, logger setup, interpolation, executor dispatch, parallel/DAG bindings, regression tests, three READMEs, Sphinx vault references, changelog and measured architecture maps. +- **Open items**: outer CLI/REST/MCP exception logging and persistent audit/history boundaries remain in `Progress.md`, alongside B1 facade/command/CLI/MCP/GUI surfaces and run-history linking. Native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index ad9ad6711..344bcf676 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-13 | 2026-10-18 | Mask resolved vault values during executor application logging | #done #security #logging | [2026-10](2026-10.md) | | U-20261018-12 | 2026-10-18 | Capture executor action lifecycles with worker parent links | #done #journal #executor | [2026-10](2026-10.md) | | U-20261018-11 | 2026-10-18 | Preserve foreign files when recovering journal tails | #incident #journal #storage | [2026-10](2026-10.md) | | U-20261018-10 | 2026-10-18 | Build versioned, redacted and durable action-journal storage | #done #journal #storage | [2026-10](2026-10.md) | @@ -375,7 +376,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 39 | +| [2026-10.md](2026-10.md) | 2026-10 | 40 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/dag/runner.py b/je_auto_control/utils/dag/runner.py index 6f7abbaf8..2e61c3593 100644 --- a/je_auto_control/utils/dag/runner.py +++ b/je_auto_control/utils/dag/runner.py @@ -303,12 +303,14 @@ def _current_run_local_runner() -> NodeRunner: from je_auto_control.utils.executor.action_executor import _running_executor from je_auto_control.utils.script_vars.execution import bound_scope from je_auto_control.utils.action_journal.capture import bound_journal, current_journal + from je_auto_control.utils.logging.secret_scope import bound_secret_scope, current_secret_scope runner = _running_executor() scope = runner.variables journal = current_journal() + secret_scope = current_secret_scope() def run(node: DagNode, definition: DagDefinition) -> Any: - with bound_scope(scope), bound_journal(journal): + with bound_scope(scope), bound_journal(journal), bound_secret_scope(secret_scope): return _default_local_runner(node, definition, runner) return run diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 3deb26f4c..eda18a035 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -34,6 +34,7 @@ ) from je_auto_control.utils.executor.action_redaction import describe_action, redact_actions from je_auto_control.utils.action_journal.capture import action_step +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS from je_auto_control.utils.llm.planner import ( plan_actions as llm_plan_actions, @@ -8197,7 +8198,6 @@ def execute_action(self, action_list: Union[list, dict], :param step_callback: 每個 action 開始前呼叫此 hook(偵錯用)。 :return: 執行紀錄字典 """ - autocontrol_logger.info(f"execute_action, action_list: {redact_actions(action_list)}") # A nested body inherits the strictness of the list that runs it, so # a failure inside an AC_loop / AC_if_* / macro under raise_on_error # reaches the enclosing AC_try / AC_retry / caller instead of being @@ -8211,8 +8211,10 @@ def execute_action(self, action_list: Union[list, dict], running = getattr(_RUNNING, "value", None) _RUNNING.value = self try: - return self._execute_list(action_list, raise_on_error, _validated, - dry_run, step_callback) + with secret_log_scope(): + autocontrol_logger.info(f"execute_action, action_list: {redact_actions(action_list)}") + return self._execute_list(action_list, raise_on_error, _validated, + dry_run, step_callback) finally: _STRICT_BODIES.value = inherited _RUNNING.value = running diff --git a/je_auto_control/utils/executor/flow_control.py b/je_auto_control/utils/executor/flow_control.py index bd3e164a5..f3e8c0c3c 100644 --- a/je_auto_control/utils/executor/flow_control.py +++ b/je_auto_control/utils/executor/flow_control.py @@ -24,6 +24,7 @@ from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.rbac.authorization import authorization_scope, current_authorization from je_auto_control.utils.action_journal.capture import bound_journal, current_journal +from je_auto_control.utils.logging.secret_scope import bound_secret_scope, current_secret_scope from je_auto_control.wrapper.auto_control_image import locate_image_center from je_auto_control.wrapper.auto_control_screen import get_pixel from je_auto_control.utils.timeouts import deadline_after @@ -471,6 +472,7 @@ def __init__(self, executor: Any, branches: list) -> None: # branch thread without it ran privileged commands unchecked. self._caller = current_authorization() self._journal = current_journal() + self._secret_scope = current_secret_scope() self.results: list = [None] * len(branches) self._failures: list = [0] * len(branches) self._errors: Dict[int, str] = {} @@ -500,7 +502,8 @@ def run_branch(self, index: int, branch: Any) -> None: _MACRO_DEPTH.value = self._macro_depth self._module.reset_recorded_failures() try: - with authorization_scope(self._caller), bound_journal(self._journal): + with (authorization_scope(self._caller), bound_journal(self._journal), + bound_secret_scope(self._secret_scope)): self.results[index] = self._branch_executor().execute_action( branch, raise_on_error=self._strict, _validated=True) self._failures[index] = self._module.recorded_failures() @@ -508,8 +511,9 @@ def run_branch(self, index: int, branch: Any) -> None: self._assertions[index] = error except Exception as error: # noqa: BLE001 # reason: see comment above self._errors[index] = repr(error) - autocontrol_logger.error( - "AC_parallel branch %d failed: %r", index, error, exc_info=True) + with bound_secret_scope(self._secret_scope): + autocontrol_logger.error( + "AC_parallel branch %d failed: %r", index, error, exc_info=True) def settle(self) -> None: """On the parent thread: count branch failures, then raise the first problem.""" diff --git a/je_auto_control/utils/logging/logging_instance.py b/je_auto_control/utils/logging/logging_instance.py index 3068ffd4c..9d9211b8a 100644 --- a/je_auto_control/utils/logging/logging_instance.py +++ b/je_auto_control/utils/logging/logging_instance.py @@ -28,6 +28,8 @@ from pathlib import Path from typing import Optional +from je_auto_control.utils.logging.secret_scope import SecretLogFilter + # 建立 AutoControlGUI 專用 logger Create dedicated logger # 只設自己這個 logger 的等級。以前這裡設的是 root:函式庫改掉宿主程式的全域 # 日誌設定,任何 import 本套件的程式都會把所有第三方 logger 的 DEBUG 灌進 @@ -37,6 +39,7 @@ # library's DEBUG records. autocontrol_logger = logging.getLogger("AutoControlGUI") autocontrol_logger.setLevel(logging.DEBUG) +autocontrol_logger.addFilter(SecretLogFilter()) # 日誌格式 Formatter formatter = logging.Formatter( diff --git a/je_auto_control/utils/logging/secret_scope.py b/je_auto_control/utils/logging/secret_scope.py new file mode 100644 index 000000000..50e6e6ff5 --- /dev/null +++ b/je_auto_control/utils/logging/secret_scope.py @@ -0,0 +1,140 @@ +"""Run-local masking of resolved vault values before application log handlers.""" +from __future__ import annotations + +import json +import logging +from contextlib import contextmanager +from contextvars import ContextVar +from threading import RLock +from typing import Iterator, Optional + + +def _first_match(text: str, start: int, forms: tuple[str, ...]) -> Optional[tuple[int, str]]: + nearest_index, nearest_form = len(text) + 1, None + for form in forms: + index = text.find(form, start) + if 0 <= index < nearest_index: + nearest_index, nearest_form = index, form + return None if nearest_form is None else (nearest_index, nearest_form) + + +class SecretLogScope: + """A shared mask for one run and its joined workers, cleared on run exit.""" + + def __init__(self) -> None: + self._lock = RLock() + self._active = True + self._forms: set[str] = set() + self._ordered: tuple[str, ...] = () + + @property + def active(self) -> bool: + """Whether the owning run is still active.""" + with self._lock: + return self._active + + @property + def has_values(self) -> bool: + """Whether formatting a log record is necessary for this run.""" + with self._lock: + return bool(self._ordered) + + def remember(self, value: str) -> None: + """Mask the exact value and common Python/JSON escaped representations.""" + if not value: + return + forms = {value, repr(value)[1:-1], json.dumps(value)[1:-1], + json.dumps(value, ensure_ascii=False)[1:-1]} + with self._lock: + if self._active and not forms.issubset(self._forms): + self._forms.update(forms) + self._ordered = tuple(sorted(self._forms, key=lambda form: (-len(form), form))) + + def scrub(self, text: str) -> str: + """Return text with known values replaced in one pass, longest first.""" + with self._lock: + if not self._ordered: + return text + # Literal matching avoids retaining secret-bearing regex patterns in + # Python's process-wide compile cache after the run has ended. + chunks: list[str] = [] + cursor = 0 + while (match := _first_match(text, cursor, self._ordered)) is not None: + index, form = match + chunks.extend((text[cursor:index], "***")) + cursor = index + len(form) + chunks.append(text[cursor:]) + return "".join(chunks) + + def close(self) -> None: + """Release all values even if an inherited context outlives this run.""" + with self._lock: + self._active = False + self._forms.clear() + self._ordered = () + + +_CURRENT: ContextVar[Optional[SecretLogScope]] = ContextVar("je_auto_control_secret_log_scope", default=None) + + +def current_secret_scope() -> Optional[SecretLogScope]: + """Return the active run mask for explicit handoff to joined workers.""" + scope = _CURRENT.get() + return scope if scope is not None and scope.active else None + + +def remember_secret(value: str) -> None: + """Register a vault resolution only for the current run's lifetime.""" + scope = current_secret_scope() + if scope is not None: + scope.remember(value) + + +@contextmanager +def bound_secret_scope(scope: Optional[SecretLogScope]) -> Iterator[None]: + """Bind a worker to its parent's mask without taking ownership of it.""" + token = _CURRENT.set(scope) + try: + yield + finally: + _CURRENT.reset(token) + + +@contextmanager +def secret_log_scope() -> Iterator[None]: + """Reuse a nested run's mask, or create and clear a top-level run mask.""" + if current_secret_scope() is not None: + yield + return + scope = SecretLogScope() + with bound_secret_scope(scope): + try: + yield + finally: + scope.close() + + +# pylint: disable-next=too-few-public-methods # reason: logging.Filter defines a single public hook +class SecretLogFilter(logging.Filter): + """Scrub before any handler, including propagated and queued handlers.""" + + def filter(self, record: logging.LogRecord) -> bool: + """Mask formatted messages, cached exception text and stack information.""" + scope = current_secret_scope() + if scope is None or not scope.has_values: + return True + try: + message = record.getMessage() + # pylint: disable-next=broad-exception-caught # reason: match logging handler containment + except Exception: # noqa: BLE001 # reason: user-defined formatting must not abort a command + message = "" + record.msg = scope.scrub(message) + record.args = () + if record.exc_info: + record.exc_text = logging.Formatter().formatException(record.exc_info) + record.exc_info = None + if record.exc_text: + record.exc_text = scope.scrub(record.exc_text) + if record.stack_info: + record.stack_info = scope.scrub(record.stack_info) + return True diff --git a/je_auto_control/utils/script_vars/interpolate.py b/je_auto_control/utils/script_vars/interpolate.py index 7bcdd840f..f381fbc15 100644 --- a/je_auto_control/utils/script_vars/interpolate.py +++ b/je_auto_control/utils/script_vars/interpolate.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Any, List, Mapping, MutableMapping, Optional, Tuple +from je_auto_control.utils.logging.secret_scope import remember_secret + # Bounded character class with a single quantifier — avoids the nested # alternation that ReDoS scanners (semgrep regex_dos) flag on # ``([A-Za-z_]\w*(?:\.\w+)*)``. Validation of the segment shape is @@ -120,6 +122,7 @@ def _lookup_secret(secret_name: str) -> str: ) from error if value is None: raise ValueError(f"Unknown secret: ${{secrets.{secret_name}}}") + remember_secret(value) return value diff --git a/test/unit_test/headless/test_runtime_secret_logs.py b/test/unit_test/headless/test_runtime_secret_logs.py new file mode 100644 index 000000000..08badd58e --- /dev/null +++ b/test/unit_test/headless/test_runtime_secret_logs.py @@ -0,0 +1,182 @@ +"""Resolved vault values stay out of application logs without changing outcomes.""" +import io +import logging +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +import pytest + +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.secrets import default_secret_manager + +PROBE = "AC_runtime_secret_probe" +FAKE = "fake-runtime-private-value" + + +@pytest.fixture +def output(monkeypatch): + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(message)s")) + autocontrol_logger.addHandler(handler) + monkeypatch.setattr(default_secret_manager, "get", lambda name: FAKE) + try: + yield stream + finally: + autocontrol_logger.removeHandler(handler) + handler.close() + + +def test_outcomes_and_command_logs_are_scrubbed_without_changing_return_values(output, monkeypatch): + def probe(value): + autocontrol_logger.info("command received %s", value) + return {"echo": value} + monkeypatch.setitem(executor.event_dict, PROBE, probe) + result = executor.execute_action([[PROBE, {"value": "${secrets.LOGIN}"}]]) + assert next(iter(result.values())) == {"echo": FAKE} + assert FAKE not in output.getvalue() + assert "command received ***" in output.getvalue() + assert "${secrets.LOGIN}" in output.getvalue() + + +@pytest.mark.parametrize("strict", [False, True]) +def test_errors_and_tracebacks_are_scrubbed(output, monkeypatch, strict): + def fail(value): + try: + raise ValueError("failure: " + value) + except ValueError: + autocontrol_logger.exception("failed with %r", value) + raise + monkeypatch.setitem(executor.event_dict, PROBE, fail) + actions = [[PROBE, {"value": "${secrets.LOGIN}"}]] + if strict: + with pytest.raises(ValueError, match=FAKE): + executor.execute_action(actions, raise_on_error=True) + else: + assert FAKE in next(iter(executor.execute_action(actions).values())) + assert FAKE not in output.getvalue() + assert "Traceback" in output.getvalue() and "ValueError" in output.getvalue() + + +@pytest.mark.parametrize("block", ["loop", "parallel", "dag"]) +def test_values_learned_by_nested_or_worker_actions_also_scrub_parent_logs(output, monkeypatch, block): + monkeypatch.setitem(executor.event_dict, PROBE, lambda value: value) + body = [[PROBE, {"value": "${secrets.LOGIN}"}]] + scripts = { + "loop": [["AC_loop", {"times": 1, "body": body}]], + "parallel": [["AC_parallel", {"branches": [body]}]], + "dag": [["AC_run_dag", {"definition": {"nodes": [{"id": "one", "actions": body}]}}]], + } + result = executor.execute_action(scripts[block]) + if block == "loop": + assert next(iter(result.values())) == 1 + else: + assert FAKE in str(result) + assert FAKE not in output.getvalue() + + +def test_run_end_clears_the_mask_and_failure_restores_the_binding(output, monkeypatch): + def fail(value): + raise ValueError(value) + monkeypatch.setitem(executor.event_dict, PROBE, fail) + with pytest.raises(ValueError): + executor.execute_action([[PROBE, {"value": "${secrets.LOGIN}"}]], raise_on_error=True) + assert FAKE not in output.getvalue() + autocontrol_logger.info("after run: %s", FAKE) + assert "after run: " + FAKE in output.getvalue() + + +def test_concurrent_runs_do_not_share_their_secret_masks(output, monkeypatch): + barrier = Barrier(2) + monkeypatch.setattr(default_secret_manager, "get", lambda name: "private-" + name) + def probe(value): + barrier.wait(timeout=5) + other = "private-RIGHT" if value == "private-LEFT" else "private-LEFT" + autocontrol_logger.info("own=%s other=%s", value, other) + return value + monkeypatch.setitem(executor.event_dict, PROBE, probe) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda name: executor.execute_action( + [[PROBE, {"value": "${secrets." + name + "}"}]]), ["LEFT", "RIGHT"])) + assert len(results) == 2 + assert "own=*** other=private-RIGHT" in output.getvalue() + assert "own=*** other=private-LEFT" in output.getvalue() + + +def test_escaped_secret_representations_are_scrubbed(output, monkeypatch): + value = "fake\\path\n'quoted'\t終" + monkeypatch.setattr(default_secret_manager, "get", lambda name: value) + monkeypatch.setitem(executor.event_dict, PROBE, lambda value: value) + result = executor.execute_action([[PROBE, {"value": "${secrets.LOGIN}"}]]) + assert next(iter(result.values())) == value + assert value not in output.getvalue() + assert repr(value)[1:-1] not in output.getvalue() + + +@pytest.mark.parametrize("strict", [False, True]) +def test_parallel_failure_handler_keeps_its_traceback_and_mask(output, monkeypatch, strict): + from je_auto_control.utils.exception.exceptions import AutoControlActionException + def fail(value): + raise ValueError(value) + monkeypatch.setitem(executor.event_dict, PROBE, fail) + actions = [["AC_parallel", {"branches": [[[PROBE, {"value": "${secrets.LOGIN}"}]]]}]] + if strict: + with pytest.raises(AutoControlActionException): + executor.execute_action(actions, raise_on_error=True) + assert "Traceback" in output.getvalue() + else: + executor.execute_action(actions) + assert FAKE not in output.getvalue() + + +def test_closed_mask_cannot_retain_values_via_an_inherited_binding(): + from je_auto_control.utils.logging.secret_scope import ( + bound_secret_scope, current_secret_scope, remember_secret, secret_log_scope, + ) + with secret_log_scope(): + scope = current_secret_scope() + remember_secret(FAKE) + assert scope.scrub(FAKE) == "***" + assert not scope.active and not scope.has_values + with bound_secret_scope(scope): + remember_secret("later-private-value") + assert current_secret_scope() is None + assert scope.scrub(FAKE) == FAKE + + +def test_mask_uses_longest_match_and_preserves_empty_secret_behavior(): + from je_auto_control.utils.logging.secret_scope import SecretLogScope + scope = SecretLogScope() + for value in ["", "ab", "abc", "***"]: + scope.remember(value) + assert scope.scrub("abc-ab-***-visible") == "***-***-***-visible" + scope.close() + + +def test_filter_masks_cached_exception_stack_and_unformattable_records(): + from je_auto_control.utils.logging.secret_scope import SecretLogFilter, remember_secret, secret_log_scope + with secret_log_scope(): + remember_secret(FAKE) + record = logging.LogRecord("AutoControlGUI", logging.INFO, __file__, 0, "%s %s", (FAKE,), None) + record.exc_text = "cached " + FAKE + record.stack_info = "stack " + FAKE + assert SecretLogFilter().filter(record) + assert record.getMessage() == "" + assert record.exc_text == "cached ***" and record.stack_info == "stack ***" + + +def test_an_unformattable_outcome_cannot_abort_execution(output, monkeypatch): + class Unformattable: + def __str__(self): + raise RuntimeError("formatter failed: " + FAKE) + value = Unformattable() + monkeypatch.setitem(executor.event_dict, PROBE, lambda value: value) + # Resolve first, then return an opaque outcome while the run's mask is active. + monkeypatch.setitem(executor.event_dict, "AC_opaque_log_probe", lambda: value) + result = executor.execute_action([ + [PROBE, {"value": "${secrets.LOGIN}"}], ["AC_opaque_log_probe"], + ]) + assert list(result.values())[-1] is value + assert FAKE not in output.getvalue() + assert "" in output.getvalue() From 2e77bfdd51ced1412e1f8dcd7e71f563691326fa Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 22:36:09 +0800 Subject: [PATCH 013/294] Isolate legacy CLI dispatch for run-scoped error handling --- architecture.md | 2 +- architecture_explore.md | 8 +++---- docs/updates/2026-10.md | 8 +++++++ docs/updates/README.md | 3 ++- je_auto_control/__main__.py | 46 +++++++++++++++++++++---------------- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/architecture.md b/architecture.md index 04fcf1120..a79e765ab 100644 --- a/architecture.md +++ b/architecture.md @@ -45,7 +45,7 @@ entry points → execution core (`utils/executor/`) → headless capabilities (` | Python facade | `import je_auto_control` | Broad historical surface (`__all__`). | | Stable API | `je_auto_control.api` → `api/core.py` | `execute_action`, `execute_action_with_vars`, `generate_code`, `run_diagnostics`, `create_failure_bundle`, `failure_bundle_on_error`, `FailureBundleOptions`. | | Main CLI | `je_auto_control` → `je_auto_control.cli:main` | Subcommands `run` (`--var`, `--dry-run`), `validate` / `lint`, `fmt`, `list-commands`, `record`, `codegen`, `failure-bundle`, `list-jobs`, `start-server`, `start-rest`, `version`. | -| Legacy CLI | `python -m je_auto_control` (`__main__.py`) | `-e/--execute_file FILE`, `-d/--execute_dir DIR`, `-c/--create_project PATH`, `--execute_str JSON`. `--execute_str` also accepts a double-encoded JSON string. Any error exits 1 with a log line rather than a traceback, and a `-d` path that is not a directory is an error. | +| Legacy CLI | `python -m je_auto_control` (`__main__.py`) | Private `_main()` owns dispatch and error conversion. `-e/--execute_file FILE`, `-d/--execute_dir DIR`, `-c/--create_project PATH`, `--execute_str JSON`. `--execute_str` also accepts a double-encoded JSON string. Any error exits 1 with a log line rather than a traceback, and a `-d` path that is not a directory is an error. | | MCP server | `je_auto_control_mcp` → `utils/mcp_server/__main__.py:main` | stdio; `start_mcp_stdio_server()`; HTTP transport via the `AC_start_mcp_http_server` command. | | REST API | `je_auto_control start-rest`, `python -m je_auto_control.utils.rest_api`, `start_rest_api_server()` | Default `127.0.0.1:9939`, bearer token + rate limit. | | TCP server | `je_auto_control start-server`, `start_autocontrol_socket_server()` | `utils/socket_server/auto_control_socket_server.py`, default `127.0.0.1:9938`, JSON action lists. | diff --git a/architecture_explore.md b/architecture_explore.md index 141ea34ca..89a94e626 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,086 | -| 程式碼總行數 | 162,807 | +| 程式碼總行數 | 162,813 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -155,7 +155,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | 模組 | 行數 | 職責 | | --- | ---: | --- | | `je_auto_control/__init__.py` | 1,994 | **套件門面**。集中匯入並再匯出 1,264 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | -| `je_auto_control/__main__.py` | 98 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | +| `je_auto_control/__main__.py` | 104 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | | `je_auto_control/cli.py` | 367 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | | `je_auto_control/api/core.py` | 19 | **穩定無頭 API 門面**:只暴露 `execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、`create_failure_bundle`、`failure_bundle_on_error`、`FailureBundleOptions`。mypy 型別契約以此為起點,現已擴到整包(見「設定基線」)。 | @@ -1087,7 +1087,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,682 | | `utils/usb/` | 17 | 4,816 | -| `je_auto_control/`(頂層 3 檔) | 3 | 2,459 | +| `je_auto_control/`(頂層 3 檔) | 3 | 2,465 | | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | @@ -1103,5 +1103,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 857 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 696 | 59,463 | -| **總計** | **1,080** | **162,742** | +| **總計** | **1,080** | **162,748** | diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 5448ff9a2..b0672ca2b 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -405,3 +405,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 15 new cases cover command logs, outcomes, strict/soft exceptions, tracebacks, nested and worker capture, parallel failure handling, escaping, concurrent isolation, cleanup and unformattable outcomes. The final focused journal/scope/flow and documentation/reason selection passes 191 tests, with 99.17% scoped mask coverage. Another 131 documentation, complexity, file-budget, exception, cross-project, light-import and log-handler checks pass. Strict mypy and the three-platform full typing gate pass; ruff and bandit pass, and pylint scores 10.00/10. Strict English/Chinese Sphinx and wheel/source builds pass; the wheel matches all six changed runtime sources. Jeffrey_RPA's three facade/control suites pass 751 tests against this working tree (five existing Discord deprecations). - **Files**: new `utils/logging/secret_scope.py`, logger setup, interpolation, executor dispatch, parallel/DAG bindings, regression tests, three READMEs, Sphinx vault references, changelog and measured architecture maps. - **Open items**: outer CLI/REST/MCP exception logging and persistent audit/history boundaries remain in `Progress.md`, alongside B1 facade/command/CLI/MCP/GUI surfaces and run-history linking. Native and paid-API acceptance remain deferred. + +## U-20261018-14 · 2026-10-18 · Isolate the legacy CLI dispatch boundary without changing flags · #done #cli #refactor + +- **What**: moved the legacy entry point's flag dispatch and error conversion into private `_main()`, with the three action-file/directory/string helpers at module level. The `__main__` guard still invokes the same pipeline. This supplies one explicit boundary for the follow-up secret-mask lifecycle change without mixing that behavior into the refactor. +- **Compatibility**: flags, double-encoded JSON, directory handling, failure messages and exit statuses are unchanged. No production consumer installation or running batch was changed. +- **Validation**: 115 CLI, legacy-flag and cross-project contract tests pass; another 33 documentation, complexity, file-budget and exception-reason gates pass. Ruff and whole-package three-platform mypy pass. +- **Files**: `je_auto_control/__main__.py`, both architecture maps and update index. +- **Open items**: outer-entry logging and persistent diagnostic masking remain in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index 344bcf676..39bd2a693 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-14 | 2026-10-18 | Isolate the legacy CLI dispatch boundary without changing flags | #done #cli #refactor | [2026-10](2026-10.md) | | U-20261018-13 | 2026-10-18 | Mask resolved vault values during executor application logging | #done #security #logging | [2026-10](2026-10.md) | | U-20261018-12 | 2026-10-18 | Capture executor action lifecycles with worker parent links | #done #journal #executor | [2026-10](2026-10.md) | | U-20261018-11 | 2026-10-18 | Preserve foreign files when recovering journal tails | #incident #journal #storage | [2026-10](2026-10.md) | @@ -376,7 +377,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 40 | +| [2026-10.md](2026-10.md) | 2026-10 | 41 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/__main__.py b/je_auto_control/__main__.py index a5b98339d..a00b21fb4 100644 --- a/je_auto_control/__main__.py +++ b/je_auto_control/__main__.py @@ -1,4 +1,4 @@ -# argparse +"""Legacy action-file CLI preserving the historical argparse flags.""" import argparse import json import os @@ -20,32 +20,34 @@ from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.project.create_project_structure import create_project_dir -if __name__ == "__main__": - try: - def preprocess_execute_action(file_path: str): - execute_action(read_executable_action_json(file_path)) +def _preprocess_execute_action(file_path: str) -> None: + execute_action(read_executable_action_json(file_path)) - def preprocess_execute_files(file_path: str): - if not os.path.isdir(file_path): - # os.walk yields nothing for a missing directory, so a typo - # ran no file at all and still exited 0. - raise AutoControlArgparseException( - f"not a directory: {file_path}") - execute_files(get_dir_files_as_list(file_path)) + +def _preprocess_execute_files(file_path: str) -> None: + if not os.path.isdir(file_path): + # os.walk yields nothing for a missing directory, so a typo + # ran no file at all and still exited 0. + raise AutoControlArgparseException( + f"not a directory: {file_path}") + execute_files(get_dir_files_as_list(file_path)) - def preprocess_read_str_execute_action(execute_str: str): - json_data = json.loads(execute_str) - if isinstance(json_data, str): - json_data = json.loads(json_data) - execute_action(json_data) +def _preprocess_read_str_execute_action(execute_str: str) -> None: + json_data = json.loads(execute_str) + if isinstance(json_data, str): + json_data = json.loads(json_data) + execute_action(json_data) +def _main() -> None: + """Run the legacy CLI with its original flags, errors and exit statuses.""" + try: argparse_event_dict = { - "execute_file": preprocess_execute_action, - "execute_dir": preprocess_execute_files, - "execute_str": preprocess_read_str_execute_action, + "execute_file": _preprocess_execute_action, + "execute_dir": _preprocess_execute_files, + "execute_str": _preprocess_read_str_execute_action, "create_project": create_project_dir } parser = argparse.ArgumentParser() @@ -96,3 +98,7 @@ def preprocess_read_str_execute_action(execute_str: str): autocontrol_logger.error("cli execution failed: %r", error) sys.stderr.write(f"error: {error}\n") sys.exit(1) + + +if __name__ == "__main__": + _main() From 8e1422e0f5cf9eca962ca0b9a629ce7977f0a3b5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 22:53:30 +0800 Subject: [PATCH 014/294] Retain run-local secret masks through diagnostic persistence --- CHANGELOG.md | 6 + Progress.md | 2 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 11 +- architecture_explore.md | 60 ++--- .../Eng/doc/new_features/new_features_doc.rst | 14 +- .../Zh/doc/new_features/new_features_doc.rst | 12 +- docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- je_auto_control/__main__.py | 6 +- je_auto_control/cli.py | 5 +- je_auto_control/utils/hotkey/hotkey_daemon.py | 2 + je_auto_control/utils/logging/secret_scope.py | 43 ++++ je_auto_control/utils/mcp_server/audit.py | 10 +- je_auto_control/utils/mcp_server/server.py | 2 + .../utils/rest_api/rest_handlers.py | 3 + je_auto_control/utils/rest_api/rest_server.py | 2 + .../utils/run_history/history_store.py | 2 + je_auto_control/utils/scheduler/scheduler.py | 2 + .../utils/test_record/record_test_class.py | 10 +- .../utils/triggers/email_trigger.py | 11 +- .../utils/triggers/trigger_engine.py | 2 + .../utils/triggers/webhook_server.py | 2 + .../test_runtime_secret_boundaries.py | 225 ++++++++++++++++++ 26 files changed, 387 insertions(+), 62 deletions(-) create mode 100644 test/unit_test/headless/test_runtime_secret_boundaries.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c2582cc..ad7434434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- CLI/REST/MCP and background run owners retain resolved-secret masking through + outer error handling and audit/history writes. CLI stderr and delayed report + snapshots are sanitized; report snapshots omit opaque/cyclic values when the + run resolved secrets. API outcomes, raised exceptions and CLI result payloads + keep their existing contents. + - Executor runs mask resolved vault-reference values in application log messages, exception text and tracebacks before handlers receive them. Nested actions and joined local parallel/DAG workers share a run-local mask, cleared on exit. diff --git a/Progress.md b/Progress.md index b2218134c..6514be368 100644 --- a/Progress.md +++ b/Progress.md @@ -222,7 +222,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 -- **executor 外層的秘密遮罩**:`__main__.py`、`utils/{rest_api,mcp_server}/` 的外層 catch 仍在 executor 遮罩結束後格式化原始例外;需將遮罩生命週期延伸到入口的錯誤日誌,並檢查 audit/run-history 的持久化邊界,保留 API 回傳契約。 +- **回呼擁有者的秘密遮罩**(`TODO`):`utils/watchdog/popup_watchdog.py`、`utils/observer/observer.py`、`utils/callback/callback_function_executor.py` 的外層錯誤日誌仍缺少涵蓋完整回呼的遮罩範圍與對應回歸測試。 - **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index 4e3f6acce..b2eb44dd2 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Facade, command, CLI/MCP and GUI controls remain pending integration. -During executor runs, values resolved from `${secrets.NAME}` are masked in `AutoControlGUI` application messages and tracebacks, including nested actions and local parallel/DAG workers. Masks are discarded at run exit; returned outcomes and raised exceptions keep their original values. +Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. ## Command-line interface diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 08fd2b5a0..729038bdd 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -247,7 +247,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。门面、动作命令、CLI/MCP 与 GUI 控制仍待接入。 -executor 执行期间,`${secrets.NAME}` 解析后的值会在 `AutoControlGUI` 应用程序消息与 traceback 中遮罩,包括嵌套动作及本机 parallel/DAG 工作线程。遮罩数据于执行结束时清除;返回结果与抛出的异常保留原始值。 +`${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。 ## 命令行界面 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c91df252a..4b7750071 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -246,7 +246,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。門面、動作指令、CLI/MCP 與 GUI 控制仍待接入。 -executor 執行期間,`${secrets.NAME}` 解析後的值會在 `AutoControlGUI` 應用程式訊息與 traceback 中遮罩,包含巢狀動作及本機 parallel/DAG 工作執行緒。遮罩資料於執行結束時清除;回傳結果與拋出的例外保留原始值。 +`${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。 ## 命令列介面 diff --git a/architecture.md b/architecture.md index a79e765ab..62070e18f 100644 --- a/architecture.md +++ b/architecture.md @@ -57,10 +57,13 @@ entry points → execution core (`utils/executor/`) → headless capabilities (` ## 4. Main flows -Executor runs own a `utils/logging/secret_scope.py` mask for vault values resolved -by interpolation. A logger filter sanitizes application records before handlers; -joined local parallel/DAG workers share the mask, independent runs isolate it, -and the owning run clears it on exit. Returned outcomes retain their values. +Executor runs use a `utils/logging/secret_scope.py` mask for vault values resolved +by interpolation. CLI/REST/MCP and scheduled/triggered/hotkey/email owners keep +the mask alive through error handling and persistent diagnostics. A logger +filter sanitizes records before handlers; audit, history and report snapshots +mask text before storage. Joined local parallel/DAG workers share the mask; +independent requests isolate it, and the owner clears it on exit. API outcomes +retain their values; CLI error output is sanitized. **A. JSON action script (primary path)** diff --git a/architecture_explore.md b/architecture_explore.md index 89a94e626..e752e7e79 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,086 | -| 程式碼總行數 | 162,813 | +| 程式碼總行數 | 162,885 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -155,8 +155,8 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | 模組 | 行數 | 職責 | | --- | ---: | --- | | `je_auto_control/__init__.py` | 1,994 | **套件門面**。集中匯入並再匯出 1,264 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | -| `je_auto_control/__main__.py` | 104 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | -| `je_auto_control/cli.py` | 367 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | +| `je_auto_control/__main__.py` | 106 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | +| `je_auto_control/cli.py` | 370 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | | `je_auto_control/api/core.py` | 19 | **穩定無頭 API 門面**:只暴露 `execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、`create_failure_bundle`、`failure_bundle_on_error`、`FailureBundleOptions`。mypy 型別契約以此為起點,現已擴到整包(見「設定基線」)。 | | `je_auto_control/utils/cli_output.py` | 44 | 本套件命令列工具與 stdio 伺服器的標準串流:不論碼頁一律 UTF-8。 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,842 行。 +> 25 個套件、約 15,844 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -299,12 +299,12 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/skill_library/` | 145 | 具名可重用 action 序列(skill)的持久化倉庫 | | `utils/state_machine/` | 277 | 宣告式有限狀態機驅動 action JSON;預設動作跨狀態共享單次執行範圍 | | `utils/stubs/` | 311 | 為 `AC_*` 指令面產生型別 stub | -| `utils/test_record/` | 70 | 全域測試紀錄單例,記錄每個動作的參數與例外 | +| `utils/test_record/` | 72 | 全域測試紀錄單例,記錄每個動作的參數與例外 | | `utils/work_queue/` | 280 | 交易式工作佇列(dispatcher/performer),支撐大量批次執行 | ### 5.4.2 框架基礎設施 -> 14 個套件、約 3,399 行。 +> 14 個套件、約 3,442 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -316,7 +316,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/exception/` | 223 | **例外階層根**。所有錯誤繼承 `AutoControlException`,加上集中式錯誤訊息字串(`exception_tags`) | | `utils/failure_bundle/` | 229 | 可攜、已遮蔽的失敗診斷 ZIP(截圖 + 診斷 + log 尾段) | | `utils/file_process/` | 40 | 目錄檔案列舉(`execute_dir` 的後端) | -| `utils/logging/` | 311 | `autocontrol_logger`、家目錄共用 handler 與 executor 執行期間的 vault 值遮罩(含本機 parallel/DAG) | +| `utils/logging/` | 354 | `autocontrol_logger`、共用 handler、executor/CLI/REST/MCP/背景 run 的 vault 值遮罩與持久化診斷快照 | | `utils/package_manager/` | 179 | 動態載入套件並把 executor 注入其中;載入前先過套件閘門(允許清單加上只能從 Python 呼叫的開關,工作區 X-12) | | `utils/path_guard/` | 231 | 命令列傳入路徑的正規化與邊界檢查(防路徑穿越) | | `utils/platform_id/` | 62 | 作業系統家族的單一判定點。`sys.platform` 原本在一百多處跟字面清單比對,而那些清單都沒有 BSD;`is_x11_unix()` 問的是「這是不是 X11 unix」,這才是守衛一直想問的問題 | @@ -325,18 +325,18 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.3 排程、觸發與背景監看 -> 11 個套件、約 4,366 行。 +> 11 個套件、約 4,377 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `utils/hotkey/` | 857 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | +| `utils/hotkey/` | 859 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | | `utils/idle_keepawake/` | 245 | 偵測使用者閒置時間並在無人值守執行期間阻止系統睡眠 | | `utils/lock_session/` | 166 | 鎖定工作站、等待解鎖並分類鎖定狀態轉換 | | `utils/observer/` | 245 | 反應式畫面觀察者,在出現/消失/變化時觸發 | | `utils/recurrence/` | 470 | RFC 5545 重複規則解析與發生時間展開 | -| `utils/scheduler/` | 512 | 間隔式與 cron 式的 action JSON 排程器 | +| `utils/scheduler/` | 514 | 間隔式與 cron 式的 action JSON 排程器 | | `utils/session_guard/` | 62 | 驅動輸入前先偵測工作階段是否已鎖定/非互動 | -| `utils/triggers/` | 1,412 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | +| `utils/triggers/` | 1,419 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | | `utils/voice/` | 99 | 語音指令路由:把辨識到的語句對應到 `AC_*` action list | | `utils/watchdog/` | 208 | 背景彈窗/中斷看門狗,供無人值守自動化 | | `utils/watcher/` | 90 | 無頭輪詢原語:滑鼠位置、像素顏色、log tail | @@ -495,7 +495,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.9 AI / Agent / LLM -> 13 個套件、約 23,686 行。 +> 13 個套件、約 23,690 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -508,7 +508,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/cua_action/` | 204 | 標準化 computer-use 動作結構(Anthropic/OpenAI → `AC_*`) | | `utils/llm/` | 372 | 自然語言 → action list 規劃器 + Anthropic/null 後端;規劃執行隔離頂層變數並沿用巢狀範圍 | | `utils/mcp_registry/` | 97 | MCP registry `server.json` 資訊清單產生(可被發現) | -| `utils/mcp_server/` | 19,164 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | +| `utils/mcp_server/` | 19,168 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | | `utils/tool_use_schema/` | 195 | 把 `AC_*` 指令匯出成 Claude/OpenAI 的 tool-use schema | | `utils/trajectory_eval/` | 132 | agent 軌跡評估:依評分規準為一次執行打分 | | `utils/vision/` | 538 | VLM 元素定位器(依描述找元素)+ Anthropic/OpenAI/null 後端 | @@ -528,7 +528,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.11 伺服器、網路協定與外部整合 -> 24 個套件、約 7,121 行。 +> 24 個套件、約 7,126 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -550,7 +550,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/otp/` | 37 | TOTP 一次性密碼產生(自動化 2FA 登入) | | `utils/outbox/` | 107 | 交易式 outbox,保證至少一次的事件投遞 | | `utils/pytest_plugin/` | 283 | pytest 外掛 + BDD step library(`pytest11` entry point) | -| `utils/rest_api/` | 2,059 | 純標準庫 REST 前端:路由、Bearer 驗證、限流、Prometheus 指標、OpenAPI 3.1 產生 | +| `utils/rest_api/` | 2,064 | 純標準庫 REST 前端:路由、Bearer 驗證、限流、Prometheus 指標、OpenAPI 3.1 產生 | | `utils/socket_server/` | 163 | 執行 action JSON 的執行緒式 TCP 指令伺服器(預設綁 127.0.0.1) | | `utils/sse_client/` | 142 | Server-Sent Events 用戶端解析 | | `utils/tls_acme/` | 473 | TLS 自動化:HTTP-01 挑戰伺服器、金鑰/CSR、自動續期 | @@ -559,7 +559,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.12 報表、可觀測性與測試治理 -> 34 個套件、約 7,726 行。 +> 34 個套件、約 7,728 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -584,7 +584,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/profiler/` | 451 | 逐動作效能剖析器 + 資源剖析器 | | `utils/quarantine/` | 204 | 易碎測試隔離區,讓套件執行器跳過已知不穩定案例 | | `utils/run_diff/` | 143 | 兩次執行軌跡的差異(LCS 對齊:新增/移除/狀態翻轉/退化) | -| `utils/run_history/` | 459 | 執行歷史儲存與產出物管理 | +| `utils/run_history/` | 461 | 執行歷史儲存與產出物管理 | | `utils/sarif/` | 187 | 以 SARIF 2.1.0 匯出發現項,供 GitHub/Azure code scanning | | `utils/slo/` | 115 | SLO 評估:SLI、錯誤預算與多視窗燃燒率告警 | | `utils/smoothing/` | 67 | 數列移動平均平滑 | @@ -709,7 +709,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 | | `flags.py` | 19 | 旗標讀取 `as_bool`:依拼法(`"true"`/`"yes"`/`"on"`/`"1"`)而非 Python 真值判斷,所以 JSON、CLI、MCP 傳來的 `"false"` 是關;所有 adapter 與流程指令共用。 | -#### `utils/mcp_server/`(19,164 行,680 個工具)— 最大子系統 +#### `utils/mcp_server/`(19,168 行,680 個工具)— 最大子系統 | 檔案 | 行數 | 職責 | | --- | ---: | --- | @@ -725,7 +725,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `tools/_handlers_executor_bridge.py` | 1,429 | 252 個純委派(中位數 3 行,最長的 16 行全是參數簽章):每個都是 `from action_executor import _x` 再 `return _x(...)`,沒有分支邏輯。超過 750 行,理由記在 `Progress.md` 的豁免表(再切只能照 MCP 工廠領域分,會把同一種委派散進十幾個沒有語意邊界的檔)。 | | `tools/_handlers_locators.py` | 436 | 同一種 adapter,定位主題:無障礙樹、智慧等待、自我修復、螢幕觀察、座標空間、視覺與 OCR、影像去重、元件倉庫、A/B 定位。 | | `tools/_handlers_operations.py` | 629 | 同一種 adapter,營運主題:agent 與其記憶/追蹤、治理與合規、成本與遙測、失敗掛鉤、看門狗、速率限制、檢查點、核可、產物與資產、測試選擇與分片、佇列與 saga。 | -| `server.py` | 734 | JSON-RPC 2.0 over stdio 的最小 MCP 伺服器:連線範圍狀態、行內/併發分派、工具與 resource/prompt 處理器;握手時代的方法表(`_run_method`),兩個協定時代的逐請求分派在 `_stateless.py`。 | +| `server.py` | 736 | JSON-RPC 2.0 over stdio 的最小 MCP 伺服器:連線範圍狀態、行內/併發分派、工具與 resource/prompt 處理器;握手時代的方法表(`_run_method`),兩個協定時代的逐請求分派在 `_stateless.py`。 | | `http_transport.py` | 727 | MCP 的 HTTP 傳輸;宣告 2026-07-28 的請求走 `_http_stateless.py` 的標頭規則,不發 session。 | | `_http_stateless.py` | 185 | MCP 2026-07-28 在 Streamable HTTP 上的規則:`MCP-Protocol-Version`/`Mcp-Method`/`Mcp-Name` 必須與 body 相符(`=?base64?…?=` 先解碼),不符是 400+`HeaderMismatch`;版本與中繼資料錯誤 400、未知方法 404。純函式,由 `http_transport.py` 回覆。 | | `http_sessions.py` | 247 | MCP 的 HTTP 傳輸用的 session 身分:`Mcp-Session-Id` 註冊表,以及每個 session 那條常駐的 server→client SSE 串流。 | @@ -742,7 +742,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `tools/_validation.py` | 122 | MCP 工具用到的 JSON Schema 子集驗證器。 | | `tools/plugin_tools.py` | 89 | 把外掛載入的 `AC_*` callable 包成 `MCPTool`。 | | `log_bridge.py` | 118 | 把 Python logging 記錄橋接成 MCP `notifications/message`;2026-07-28 的請求只收到自己設了 `logLevel` 時產生的記錄。 | -| `audit.py` | 112 | MCP 工具呼叫稽核記錄。 | +| `audit.py` | 114 | MCP 工具呼叫稽核記錄。 | | `context.py` | 71 | 傳給 opt-in 工具處理器的每次呼叫上下文。 | | `rate_limit.py` | 48 | 工具呼叫的 token bucket 限流。 | | `__main__.py` | 92 | `je_auto_control_mcp` console script 進入點。 | @@ -821,12 +821,12 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `usbip/libusb_backend.py` | 224 | 以 PyUSB/libusb 執行 URB 的正式後端。 | | `usbip/backend.py` | 87 | 可插拔 URB 執行後端。 | -#### `utils/rest_api/`(2,059 行) +#### `utils/rest_api/`(2,064 行) | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `rest_server.py` | 650 | HTTP 前端主體;相容 check-only gate,RBAC 必須帶使用者身分。 | -| `rest_handlers.py` | 528 | 端點實作。 | +| `rest_server.py` | 652 | HTTP 前端主體;相容 check-only gate,RBAC 必須帶使用者身分。 | +| `rest_handlers.py` | 531 | 端點實作。 | | `rest_openapi.py` | 443 | 走訪路由表產生 OpenAPI 3.1 規格。 | | `rest_auth.py` | 202 | Bearer token 驗證 + 逐 client 限流閘門。 | | `rest_metrics.py` | 75 | Prometheus 曝露端點。 | @@ -1083,25 +1083,25 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | | `gui/` | 100 | 28,788 | -| `utils/mcp_server/` | 37 | 19,164 | +| `utils/mcp_server/` | 37 | 19,168 | | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,682 | | `utils/usb/` | 17 | 4,816 | -| `je_auto_control/`(頂層 3 檔) | 3 | 2,465 | +| `je_auto_control/`(頂層 3 檔) | 3 | 2,470 | | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | -| `utils/rest_api/` | 8 | 2,059 | +| `utils/rest_api/` | 8 | 2,064 | | `utils/agent/` | 9 | 2,142 | | `linux_with_x11/` | 19 | 1,281 | | `linux_wayland/` | 17 | 2,921 | -| `utils/triggers/` | 4 | 1,412 | +| `utils/triggers/` | 4 | 1,419 | | `utils/ocr/` | 10 | 1,381 | | `utils/usbip/` | 5 | 1,008 | | `utils/assertion/` | 3 | 887 | | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | -| `utils/hotkey/` | 7 | 857 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 696 | 59,463 | -| **總計** | **1,080** | **162,748** | +| `utils/hotkey/` | 7 | 859 | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 696 | 59,512 | +| **總計** | **1,080** | **162,820** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index aef15d6cf..edcbebdd7 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1352,10 +1352,16 @@ with ``***`` in ``AutoControlGUI`` application messages, exception text and stack information before handlers receive them. Exact values and common Python/JSON escaped forms are masked. Nested actions and joined local parallel/DAG workers share the run's mask; concurrent independent runs have -separate masks, and run exit clears the values even on failure. API outcomes -and raised exceptions retain their original values. This masking covers -values learned during that executor run; transformed/encoded values and -other applications' loggers require their own handling. +separate masks. CLI/REST/MCP requests and scheduled, trigger, hotkey and email +owners keep their mask through outer error handling and audit/history writes. +CLI stderr is masked. Report records snapshot known values before the mask is +cleared; cyclic and opaque diagnostic objects are replaced without calling +arbitrary repr or deepcopy. Audit fields are sanitized before JSON encoding, +and history error text before SQLite writes. Owner exit clears the mask even +on failure. API outcomes, raised exceptions and CLI result payloads retain +their original values. This masking covers values learned during the owned +run; transformed/encoded values and other applications' loggers require +their own handling. GUI: **Secrets** tab — initialize the vault, unlock it, add / remove entries, change passphrase. The vault file is created with mode 0o600 diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index 006f18de5..fbd9d1556 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1255,10 +1255,14 @@ Action JSON 指令:: executor 執行期間,解析後的 ``${secrets.NAME}`` 值會在送到 handler 前,於 ``AutoControlGUI`` 應用程式訊息、例外文字與堆疊資訊中替換為 ``***``。 遮罩包含原始值與常見 Python/JSON 跳脫形式。巢狀動作及等候完成的本機 -parallel/DAG 工作執行緒共用當次執行的遮罩;各個獨立執行彼此隔離, -即使失敗也會於執行結束時清除資料。API 回傳結果與拋出的例外仍保留原始值。 -此遮罩涵蓋該次 executor 執行期間解析的值;經轉換/編碼的值,以及其他 -應用程式的 logger,仍由各自的邊界處理。 +parallel/DAG 工作執行緒共用當次執行的遮罩;各個獨立執行彼此隔離。 +CLI/REST/MCP 請求與排程、trigger、hotkey、email 擁有者會將遮罩保留到 +外層錯誤處理及 audit/history 寫入完成。CLI stderr 也會遮罩。報表紀錄在遮罩 +清除前保存已處理的快照;循環與不透明診斷物件會替換為標記,不呼叫任意 repr +或 deepcopy。Audit 欄位在 JSON 編碼前處理,history 錯誤文字在 SQLite 寫入前處理。 +即使失敗也會於擁有者結束時清除資料。API 結果、拋出的例外與 CLI 結果資料保留原始值。 +此遮罩涵蓋當次執行期間解析的值;經轉換/編碼的值,以及其他應用程式的 logger, +仍由各自的邊界處理。 GUI: **Secrets** 分頁 — 建立 vault、解鎖、新增 / 移除條目、變更 通行碼。POSIX 系統上 vault 檔以 0o600 建立;Windows 預設 ACL 已限制 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index b0672ca2b..4085e727b 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -413,3 +413,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 115 CLI, legacy-flag and cross-project contract tests pass; another 33 documentation, complexity, file-budget and exception-reason gates pass. Ruff and whole-package three-platform mypy pass. - **Files**: `je_auto_control/__main__.py`, both architecture maps and update index. - **Open items**: outer-entry logging and persistent diagnostic masking remain in `Progress.md`. + +## U-20261018-15 · 2026-10-18 · Keep secret masking alive through outer errors and diagnostic writes · #done #security #diagnostics + +- **What**: CLI/REST/MCP requests and scheduled, trigger, hotkey and email run owners retain the resolved-vault mask through outer exception logging and diagnostic writes. CLI stderr is scrubbed. MCP audit fields are masked before JSON encoding, history errors before SQLite writes, and test-report records snapshot known values before later rendering. Opaque/cyclic report values and opaque keys are replaced without invoking repr/deepcopy. +- **Compatibility**: API results, raised exception objects and CLI result payloads keep their original values; changes apply to diagnostic text and persistent records. Existing authority/variable scopes remain separate. Source parameters are not mutated. +- **Validation**: the full coverage run passes 11,660 tests with 69 skipped and 87.73% coverage; the mask module has 99.37% coverage. 18 new boundary cases and the 15 earlier mask cases pass. They cover both CLI entries, real loopback REST requests, MCP error results/audit, valid JSON with escaped secrets, six background run types, direct email-history execution, source-preserving report snapshots and opaque/cyclic data. Another 44 documentation, complexity, file-budget, exception and light-import checks pass. Strict leaf mypy and three-platform full typing pass; ruff/bandit pass and pylint scores 10.00/10. Strict English/Chinese Sphinx and wheel/source builds pass; the wheel matches all 14 changed production sources. Jeffrey_RPA's three facade/control suites pass 751 tests against this tree (five existing Discord deprecations). +- **Files**: entry and background-owner scopes, `utils/logging/secret_scope.py`, MCP audit, run history, test-record snapshots, regression tests, three READMEs, Sphinx vault guide, changelog and measured architecture maps. +- **Open items**: watchdog, observer and callback-executor owner scopes remain in `Progress.md`, alongside B1 surfaces/history linking. Native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index 39bd2a693..831e0f70e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-15 | 2026-10-18 | Keep secret masking alive through outer errors and diagnostic writes | #done #security #diagnostics | [2026-10](2026-10.md) | | U-20261018-14 | 2026-10-18 | Isolate the legacy CLI dispatch boundary without changing flags | #done #cli #refactor | [2026-10](2026-10.md) | | U-20261018-13 | 2026-10-18 | Mask resolved vault values during executor application logging | #done #security #logging | [2026-10](2026-10.md) | | U-20261018-12 | 2026-10-18 | Capture executor action lifecycles with worker parent links | #done #journal #executor | [2026-10](2026-10.md) | @@ -377,7 +378,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 41 | +| [2026-10.md](2026-10.md) | 2026-10 | 42 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/__main__.py b/je_auto_control/__main__.py index a00b21fb4..de75760f9 100644 --- a/je_auto_control/__main__.py +++ b/je_auto_control/__main__.py @@ -18,6 +18,7 @@ get_dir_files_as_list from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import scrub_secret_text, secret_log_scope from je_auto_control.utils.project.create_project_structure import create_project_dir @@ -41,6 +42,7 @@ def _preprocess_read_str_execute_action(execute_str: str) -> None: execute_action(json_data) +@secret_log_scope() def _main() -> None: """Run the legacy CLI with its original flags, errors and exit statuses.""" try: @@ -90,13 +92,13 @@ def _main() -> None: # failed -e printed nothing at all. except AutoControlArgparseException as error: autocontrol_logger.error("argparse failure: %r", error) - sys.stderr.write(f"error: {error}\n") + sys.stderr.write(f"error: {scrub_secret_text(str(error))}\n") sys.exit(1) # AutoControlException: a missing or invalid action file used to end in # a raw traceback rather than this message (the exit status was 1 then too). except (OSError, ValueError, RuntimeError, AutoControlException) as error: autocontrol_logger.error("cli execution failed: %r", error) - sys.stderr.write(f"error: {error}\n") + sys.stderr.write(f"error: {scrub_secret_text(str(error))}\n") sys.exit(1) diff --git a/je_auto_control/cli.py b/je_auto_control/cli.py index 2ba2fbbba..01eee1fd2 100644 --- a/je_auto_control/cli.py +++ b/je_auto_control/cli.py @@ -29,6 +29,8 @@ import time from typing import Callable, Dict, List, Optional, Sequence +from je_auto_control.utils.logging.secret_scope import scrub_secret_text, secret_log_scope + from je_auto_control.utils.exception.exceptions import ( AutoControlActionException, AutoControlException, @@ -349,6 +351,7 @@ def _port(text: str) -> int: return port +@secret_log_scope() def main(argv: Optional[List[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) @@ -359,7 +362,7 @@ def main(argv: Optional[List[str]] = None) -> int: except (AutoControlActionException, AutoControlException, AutoControlJsonActionException, OSError, RuntimeError, ValueError) as error: - sys.stderr.write(f"error: {error}\n") + sys.stderr.write(f"error: {scrub_secret_text(str(error))}\n") return 1 diff --git a/je_auto_control/utils/hotkey/hotkey_daemon.py b/je_auto_control/utils/hotkey/hotkey_daemon.py index 2d45b02ff..2850517b0 100644 --- a/je_auto_control/utils/hotkey/hotkey_daemon.py +++ b/je_auto_control/utils/hotkey/hotkey_daemon.py @@ -20,6 +20,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, @@ -213,6 +214,7 @@ def stop(self, timeout: float = 2.0) -> None: self._thread.join(timeout=timeout) self._thread = None + @secret_log_scope() def _fire_binding(self, binding_id: str) -> None: with self._lock: match = self._bindings.get(binding_id) diff --git a/je_auto_control/utils/logging/secret_scope.py b/je_auto_control/utils/logging/secret_scope.py index 50e6e6ff5..59fdc03df 100644 --- a/je_auto_control/utils/logging/secret_scope.py +++ b/je_auto_control/utils/logging/secret_scope.py @@ -90,6 +90,49 @@ def remember_secret(value: str) -> None: scope.remember(value) +def scrub_secret_text(text: str) -> str: + """Mask known run-local values before a diagnostic is printed or persisted.""" + scope = current_secret_scope() + return text if scope is None else scope.scrub(text) + + +def _scrub_key(key: object, scope: SecretLogScope) -> object: + if isinstance(key, str): + return scope.scrub(key) + return key if key is None or isinstance(key, (bool, int, float)) else "" + + +def _scrub_data(value: object, scope: SecretLogScope, seen: set[int]) -> object: + if isinstance(value, str): + return scope.scrub(value) + if value is None or isinstance(value, (bool, int, float)): + return value + if not isinstance(value, (dict, list, tuple)): + return "" + identity = id(value) + if identity in seen: + return "" + seen.add(identity) + try: + if isinstance(value, dict): + return {_scrub_key(key, scope): _scrub_data(item, scope, seen) + for key, item in value.items()} + items = [_scrub_data(item, scope, seen) for item in value] + return tuple(items) if isinstance(value, tuple) else items + finally: + seen.remove(identity) + + +def scrub_secret_data(value: object) -> object: + """Snapshot diagnostics while masking strings and omitting opaque/cyclic values. + + Outside a run that resolved secrets the original value is returned. This + protects delayed report rendering without calling arbitrary repr/deepcopy. + """ + scope = current_secret_scope() + return value if scope is None or not scope.has_values else _scrub_data(value, scope, set()) + + @contextmanager def bound_secret_scope(scope: Optional[SecretLogScope]) -> Iterator[None]: """Bind a worker to its parent's mask without taking ownership of it.""" diff --git a/je_auto_control/utils/mcp_server/audit.py b/je_auto_control/utils/mcp_server/audit.py index 71b7eb172..ec39485f0 100644 --- a/je_auto_control/utils/mcp_server/audit.py +++ b/je_auto_control/utils/mcp_server/audit.py @@ -18,6 +18,7 @@ from je_auto_control.utils.executor.action_redaction import SENSITIVE_ARGUMENT_NAMES, redact_actions from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import scrub_secret_text from je_auto_control.utils.rbac.authorization import current_authorization @@ -69,7 +70,7 @@ def record(self, *, tool: str, arguments: Dict[str, Any], if caller is not None and caller.user_id == user_id: entry["role"] = caller.role if error_text is not None: - entry["error"] = error_text + entry["error"] = scrub_secret_text(error_text) if artifact_path is not None: entry["artifact_path"] = artifact_path line = json.dumps(entry, ensure_ascii=False, default=str) @@ -99,13 +100,14 @@ def _sanitise(arguments: Any) -> Any: if isinstance(arguments, list): return [_sanitise(item) for item in redact_actions(arguments)] if not isinstance(arguments, dict): - return arguments + return scrub_secret_text(arguments) if isinstance(arguments, str) else arguments out: Dict[str, Any] = {} for key, value in arguments.items(): + safe_key = scrub_secret_text(key) if isinstance(key, str) else key if str(key).lower() in REDACTED_KEYS or str(key).lower() == "key": - out[key] = REDACTED_PLACEHOLDER + out[safe_key] = REDACTED_PLACEHOLDER else: - out[key] = _sanitise(value) + out[safe_key] = _sanitise(value) return out diff --git a/je_auto_control/utils/mcp_server/server.py b/je_auto_control/utils/mcp_server/server.py index c36644f04..aa7ede38c 100644 --- a/je_auto_control/utils/mcp_server/server.py +++ b/je_auto_control/utils/mcp_server/server.py @@ -17,6 +17,7 @@ from je_auto_control.utils.cli_output import utf8_stream from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.mcp_server._authz import authorize_tool_call, visible_tools from je_auto_control.utils.mcp_server.audit import AuditLogger from je_auto_control.utils.mcp_server.context import ( @@ -637,6 +638,7 @@ def _prepare_tool_call( self._maybe_confirm_destructive(name, tool, arguments) return name, tool, arguments + @secret_log_scope() def _handle_tools_call(self, msg_id: Any, params: Dict[str, Any]) -> Dict[str, Any]: try: diff --git a/je_auto_control/utils/rest_api/rest_handlers.py b/je_auto_control/utils/rest_api/rest_handlers.py index 2623f1cee..b6ab32b44 100644 --- a/je_auto_control/utils/rest_api/rest_handlers.py +++ b/je_auto_control/utils/rest_api/rest_handlers.py @@ -18,6 +18,7 @@ AutoControlException, AutoControlJsonActionException, ) from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.script_vars.execution import execution_scope @@ -183,6 +184,7 @@ def _execute_strict(actions: Any) -> HandlerResult: return 200, {"ok": True, "result": result} +@secret_log_scope() def handle_execute(ctx: RouteContext) -> HandlerResult: if not isinstance(ctx.body, dict): return 400, {"error": "body must be JSON object"} @@ -208,6 +210,7 @@ def handle_execute(ctx: RouteContext) -> HandlerResult: return 200, {"result": result} +@secret_log_scope() def handle_execute_file(ctx: RouteContext) -> HandlerResult: if not isinstance(ctx.body, dict): return 400, {"error": "body must be JSON object"} diff --git a/je_auto_control/utils/rest_api/rest_server.py b/je_auto_control/utils/rest_api/rest_server.py index 0e07d30a8..17eddac1d 100644 --- a/je_auto_control/utils/rest_api/rest_server.py +++ b/je_auto_control/utils/rest_api/rest_server.py @@ -22,6 +22,7 @@ bearer_challenge, log_safe, parse_content_length, wire_json_text, ) from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.authorization import ( AuthorizationContext, authorization_scope, user_store_from_env, ) @@ -180,6 +181,7 @@ def do_POST(self) -> None: # noqa: N802 # reason: stdlib API # against the rate limit or the lockout. self._dispatch("POST", _POST_ROUTES, body=_BODY_PENDING) + @secret_log_scope() def _dispatch(self, method: str, routes: Dict[str, HandlerFn], body: Any) -> None: parsed = urlparse(self.path) diff --git a/je_auto_control/utils/run_history/history_store.py b/je_auto_control/utils/run_history/history_store.py index 17b2eb6cb..a0b63fb84 100644 --- a/je_auto_control/utils/run_history/history_store.py +++ b/je_auto_control/utils/run_history/history_store.py @@ -14,6 +14,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import scrub_secret_text from je_auto_control.utils.sqlite_support import ( SQLITE_ERRORS, last_row_id, require_sqlite3, sqlite_errors_as, ) @@ -193,6 +194,7 @@ def finish_run(self, run_id: int, status: str, if status == STATUS_RUNNING: raise ValueError("cannot finish a run with status=running") ts = float(finished_at) if finished_at is not None else time.time() + error_text = scrub_secret_text(error_text) if error_text is not None else None with self._lock: cursor = self._connection().execute( "UPDATE runs SET finished_at = ?, status = ?, error_text = ?," diff --git a/je_auto_control/utils/scheduler/scheduler.py b/je_auto_control/utils/scheduler/scheduler.py index f27cc1811..4f8a66498 100644 --- a/je_auto_control/utils/scheduler/scheduler.py +++ b/je_auto_control/utils/scheduler/scheduler.py @@ -12,6 +12,7 @@ from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, @@ -228,6 +229,7 @@ def _tick_once(self) -> None: self._in_flight.difference_update( job.job_id for job in pending) + @secret_log_scope() def _fire(self, job: ScheduledJob, now_mono: float, now_wall: float) -> None: run_id = default_history_store.start_run( SOURCE_SCHEDULER, job.job_id, job.script_path, diff --git a/je_auto_control/utils/test_record/record_test_class.py b/je_auto_control/utils/test_record/record_test_class.py index f199a6453..3f197f0d7 100644 --- a/je_auto_control/utils/test_record/record_test_class.py +++ b/je_auto_control/utils/test_record/record_test_class.py @@ -1,6 +1,8 @@ import datetime from typing import Optional from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import scrub_secret_data, scrub_secret_text +from je_auto_control.utils.executor.action_redaction import redact_actions class TestRecord: @@ -59,12 +61,12 @@ def record_action_to_list(function_name: str, local_param, test_record_instance.test_record_list.append( { "function_name": function_name, - "local_param": local_param, + "local_param": redact_actions(scrub_secret_data(local_param)), "time": datetime.datetime.now().isoformat(), # 使用 ISO 格式更標準 # Callers already pass repr(error); a second repr() showed it in # every report as '"ValueError(\'boom\')"'. repr(None) stays "None", # which is how the reports tell a success from a failure. - "program_exception": (program_exception if isinstance(program_exception, str) - else repr(program_exception)), + "program_exception": scrub_secret_text(program_exception if isinstance(program_exception, str) + else repr(program_exception)), } - ) \ No newline at end of file + ) diff --git a/je_auto_control/utils/triggers/email_trigger.py b/je_auto_control/utils/triggers/email_trigger.py index 085593c46..a13c1ee6d 100644 --- a/je_auto_control/utils/triggers/email_trigger.py +++ b/je_auto_control/utils/triggers/email_trigger.py @@ -27,6 +27,7 @@ from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import scrub_secret_text, secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, @@ -350,7 +351,7 @@ def _run(self, stop: threading.Event) -> None: try: self._poll_one(trigger) except Exception as error: # noqa: BLE001 # reason: see above - trigger.last_error = repr(error) + trigger.last_error = scrub_secret_text(repr(error)) autocontrol_logger.error( "email trigger %s poll failed: %r", trigger.trigger_id, error, exc_info=True, @@ -443,10 +444,11 @@ def _check_uidvalidity(self, client: imaplib.IMAP4, trigger: EmailTrigger) -> No def _record_connect_error(self, trigger: EmailTrigger, error: Exception) -> None: - trigger.last_error = repr(error) + trigger.last_error = scrub_secret_text(repr(error)) autocontrol_logger.error("imap %s connect failed: %r", trigger.trigger_id, error) + @secret_log_scope() def _fire_for_uid(self, client: imaplib.IMAP4, trigger: EmailTrigger, uid: str) -> int: msg = _fetch_message(client, uid) @@ -455,7 +457,7 @@ def _fire_for_uid(self, client: imaplib.IMAP4, try: payload = _build_payload(uid, msg) except Exception as error: # noqa: BLE001 # reason: a message the email package cannot read is skipped, not retried forever - trigger.last_error = repr(error) + trigger.last_error = scrub_secret_text(repr(error)) autocontrol_logger.error("imap %s unreadable message %s: %r", trigger.trigger_id, uid, error) trigger._seen_uids.add(uid) @@ -470,7 +472,7 @@ def _fire_for_uid(self, client: imaplib.IMAP4, try: self._execute_with_history(trigger, payload) except Exception as error: # noqa: BLE001 # reason: recorded; the uid must still be marked seen - trigger.last_error = repr(error) + trigger.last_error = scrub_secret_text(repr(error)) autocontrol_logger.error("imap %s fire failed: %r", trigger.trigger_id, error) else: @@ -480,6 +482,7 @@ def _fire_for_uid(self, client: imaplib.IMAP4, _mark_seen(client, uid) return 1 + @secret_log_scope() def _execute_with_history(self, trigger: EmailTrigger, payload: Dict[str, Any]) -> None: with self._fire_lock: diff --git a/je_auto_control/utils/triggers/trigger_engine.py b/je_auto_control/utils/triggers/trigger_engine.py index 90236d71b..2ddcdc16c 100644 --- a/je_auto_control/utils/triggers/trigger_engine.py +++ b/je_auto_control/utils/triggers/trigger_engine.py @@ -17,6 +17,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.timeouts import clamp_poll_interval from je_auto_control.utils.run_history.artifact_manager import ( @@ -361,6 +362,7 @@ def _is_fired_safely(self, trigger: _TriggerBase) -> bool: ) return False + @secret_log_scope() def _fire(self, trigger: _TriggerBase, now: float) -> None: with self._lock: if self._triggers.get(trigger.trigger_id) is not trigger: diff --git a/je_auto_control/utils/triggers/webhook_server.py b/je_auto_control/utils/triggers/webhook_server.py index 386ceacb2..8bd560890 100644 --- a/je_auto_control/utils/triggers/webhook_server.py +++ b/je_auto_control/utils/triggers/webhook_server.py @@ -41,6 +41,7 @@ ) from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.authorization import AuthorizationError from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( @@ -361,6 +362,7 @@ def authorize(self, trigger: WebhookTrigger, return hmac.compare_digest(token.strip().encode("utf-8"), trigger.token.encode("utf-8")) + @secret_log_scope() def fire(self, trigger: WebhookTrigger, payload: Dict[str, Any]) -> Optional[int]: """Run the trigger's script with ``payload`` seeded into variables. diff --git a/test/unit_test/headless/test_runtime_secret_boundaries.py b/test/unit_test/headless/test_runtime_secret_boundaries.py new file mode 100644 index 000000000..461d7acd5 --- /dev/null +++ b/test/unit_test/headless/test_runtime_secret_boundaries.py @@ -0,0 +1,225 @@ +"""Run owners keep vault-derived values out of diagnostics and persisted records.""" +import http.client +import io +import json +import logging +import sys +from email.message import EmailMessage + +import pytest + +from je_auto_control.utils.exception.exceptions import AutoControlAssertionException +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import remember_secret, secret_log_scope +from je_auto_control.utils.mcp_server.audit import AuditLogger +from je_auto_control.utils.run_history.history_store import HistoryStore, SOURCE_MANUAL, STATUS_ERROR +from je_auto_control.utils.secrets import default_secret_manager + +FAKE = "fake-boundary-private-value" +PROBE = "AC_secret_boundary_probe" +ACTIONS = [[PROBE, {"value": "${secrets.LOGIN}"}]] + + +@pytest.fixture +def output(monkeypatch): + stream = io.StringIO() + handler = logging.StreamHandler(stream) + autocontrol_logger.addHandler(handler) + monkeypatch.setattr(default_secret_manager, "get", lambda name: FAKE) + def fail(value): + raise AutoControlAssertionException("private failure: " + value) + monkeypatch.setitem(executor.event_dict, PROBE, fail) + try: + yield stream + finally: + autocontrol_logger.removeHandler(handler) + handler.close() + + +@pytest.fixture +def script(tmp_path): + path = tmp_path / "actions.json" + path.write_text(json.dumps(ACTIONS), encoding="utf-8") + return path + + +@pytest.fixture +def history(monkeypatch): + from je_auto_control.utils.hotkey import hotkey_daemon + from je_auto_control.utils.scheduler import scheduler + from je_auto_control.utils.triggers import email_trigger, trigger_engine, webhook_server + store = HistoryStore() + for module in (hotkey_daemon, scheduler, email_trigger, trigger_engine, webhook_server): + monkeypatch.setattr(module, "default_history_store", store) + monkeypatch.setattr(module, "capture_error_snapshot", lambda *_args: None) + try: + yield store + finally: + store.close() + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_cli_errors_are_masked_through_the_outer_error_handler(legacy, output, script, monkeypatch, capsys): + if legacy: + from je_auto_control import __main__ as cli + monkeypatch.setattr(sys, "argv", ["je_auto_control", "-e", str(script)]) + with pytest.raises(SystemExit) as raised: + cli._main() + assert raised.value.code == 1 + else: + from je_auto_control.cli import main + assert main(["run", str(script)]) == 1 + error = capsys.readouterr().err + assert FAKE not in output.getvalue() and FAKE not in error + assert "private failure: ***" in error + + +@pytest.mark.parametrize("strict", [False, True]) +def test_real_local_rest_request_keeps_the_mask_until_error_handling_finishes(output, strict): + from je_auto_control.utils.rest_api.rest_server import RestApiServer + server = RestApiServer("127.0.0.1", 0, enable_audit=False) + server.start() + connection = http.client.HTTPConnection(*server.address, timeout=5) + try: + connection.request("POST", "/execute", json.dumps({"actions": ACTIONS, "raise_on_error": strict}), + {"Content-Type": "application/json", "Authorization": "Bearer " + server.token}) + response = connection.getresponse() + payload = json.loads(response.read()) + assert response.status == (200 if strict else 500) + if strict: + assert payload["ok"] is False and FAKE in payload["error"] + assert FAKE not in output.getvalue() + finally: + connection.close() + server.stop() + + +def test_mcp_error_audit_is_masked_while_the_result_contract_stays_intact(output, tmp_path, monkeypatch): + from je_auto_control.utils.mcp_server import server as module + from je_auto_control.utils.mcp_server.tools import MCPTool + monkeypatch.setattr(module, "_capture_error_screenshot", lambda *_args: None) + path = tmp_path / "audit.jsonl" + tool = MCPTool("probe", "controlled error", {"type": "object", "properties": {}}, + lambda: executor.execute_action(ACTIONS)) + server = module.MCPServer(tools=[tool], audit_logger=AuditLogger(str(path))) + response = json.loads(server.handle_line(json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "probe"}, + }))) + assert response["result"]["isError"] is True + assert FAKE in response["result"]["content"][0]["text"] + assert FAKE not in output.getvalue() + assert FAKE not in path.read_text(encoding="utf-8") + assert "***" in json.loads(path.read_text(encoding="utf-8"))["error"] + + +@pytest.mark.parametrize("value", [FAKE, 'fake"line\nprivate']) +def test_audit_masks_fields_before_json_encoding_without_mutating_arguments(tmp_path, value): + path = tmp_path / "audit.jsonl" + arguments = {"nested": [value], "reference": "${secrets.LOGIN}"} + with secret_log_scope(): + remember_secret(value) + AuditLogger(str(path)).record(tool="probe", arguments=arguments, status="error", + duration_seconds=0, error_text="failed " + value) + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["arguments"]["nested"] == ["***"] + assert payload["arguments"]["reference"] == "${secrets.LOGIN}" + assert payload["error"] == "failed ***" + assert arguments["nested"] == [value] + + +def test_history_masks_before_the_sqlite_write(history): + run = history.start_run(SOURCE_MANUAL, "manual", "script.json") + with secret_log_scope(): + remember_secret(FAKE) + history.finish_run(run, STATUS_ERROR, "failed " + FAKE) + assert history.get_run(run).error_text == "failed ***" + + +@pytest.mark.parametrize("kind", ["interval", "cron", "trigger", "hotkey", "webhook", "email"]) +def test_background_run_owner_masks_history_and_outer_error_logs(kind, output, script, history, monkeypatch): + if kind in ("interval", "cron"): + from je_auto_control.utils.scheduler.scheduler import Scheduler + owner = Scheduler() + job = (owner.add_job(str(script), 60) if kind == "interval" + else owner.add_cron_job(str(script), "* * * * *")) + owner._fire(job, 0, 1_800_000_000) + elif kind == "trigger": + from je_auto_control.utils.triggers.trigger_engine import FilePathTrigger, TriggerEngine + owner = TriggerEngine() + trigger = FilePathTrigger("trigger", str(script)) + owner.add(trigger) + owner._fire(trigger, 0) + elif kind == "hotkey": + from je_auto_control.utils.hotkey.hotkey_daemon import HotkeyDaemon + owner = HotkeyDaemon() + owner.bind("ctrl+alt+a", str(script), binding_id="probe") + owner._fire_binding("probe") + elif kind == "webhook": + from je_auto_control.utils.triggers.webhook_server import WebhookTriggerServer + owner = WebhookTriggerServer() + trigger = owner.add(path="/probe", script_path=str(script)) + assert owner.fire(trigger, {}) is not None + else: + from je_auto_control.utils.triggers import email_trigger + owner = email_trigger.EmailTriggerWatcher() + trigger = owner.add("imap.invalid", "user", "unused", str(script), mark_seen=False) + message = EmailMessage() + message.set_content("controlled message") + monkeypatch.setattr(email_trigger, "_fetch_message", lambda *_args: message) + assert owner._fire_for_uid(object(), trigger, "1") == 1 + assert FAKE not in trigger.last_error + row = history.list_runs()[0] + assert row.status == STATUS_ERROR + assert FAKE not in row.error_text and "***" in row.error_text + assert FAKE not in output.getvalue() + + +def test_direct_email_history_preserves_the_raised_exception_but_not_its_persisted_secret(output, script, history): + from je_auto_control.utils.triggers.email_trigger import EmailTriggerWatcher + owner = EmailTriggerWatcher() + trigger = owner.add("imap.invalid", "user", "unused", str(script)) + with pytest.raises(AutoControlAssertionException, match=FAKE): + owner._execute_with_history(trigger, {}) + assert FAKE not in history.list_runs()[0].error_text + + +def test_later_reports_cannot_persist_a_resolved_secret_from_the_action_record(output, monkeypatch): + from je_auto_control.utils.test_record.record_test_class import record_action_to_list, test_record_instance + source = {} + def probe(value): + source["value"] = value + record_action_to_list("probe", source, "failed " + value) + return value + monkeypatch.setitem(executor.event_dict, PROBE, probe) + monkeypatch.setattr(test_record_instance, "init_record", True) + monkeypatch.setattr(test_record_instance, "test_record_list", []) + result = executor.execute_action(ACTIONS) + assert list(result.values()) == [FAKE] and source == {"value": FAKE} + assert FAKE not in json.dumps(test_record_instance.test_record_list) + assert test_record_instance.test_record_list[0]["local_param"]["value"] == "***" + + +def test_secret_diagnostics_snapshot_cycles_and_opaque_values_without_repr(): + from je_auto_control.utils.logging.secret_scope import scrub_secret_data + class Unsafe: + def __repr__(self): + raise AssertionError("a diagnostic must not format this object") + source = {"value": FAKE, "opaque": Unsafe(), "tuple": (FAKE, 1, True, None)} + source[Unsafe()] = "visible" + source["cycle"] = source + with secret_log_scope(): + remember_secret(FAKE) + result = scrub_secret_data(source) + assert result == {"value": "***", "opaque": "", "tuple": ("***", 1, True, None), + "cycle": "", "": "visible"} + assert source["value"] == FAKE and source["cycle"] is source + assert FAKE not in json.dumps(result) + + +def test_audit_masks_dynamic_argument_keys_before_writing(tmp_path): + path = tmp_path / "audit.jsonl" + with secret_log_scope(): + remember_secret(FAKE) + AuditLogger(str(path)).record(tool="probe", arguments={FAKE: "visible"}, status="ok", duration_seconds=0) + assert json.loads(path.read_text(encoding="utf-8"))["arguments"] == {"***": "visible"} From 517eb0bef43e5ad9ceeb759d6adc2db53ef682b1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 23:01:04 +0800 Subject: [PATCH 015/294] Keep callback failures inside their secret-mask owners --- CHANGELOG.md | 5 + Progress.md | 1 - README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 4 +- architecture_explore.md | 16 +-- .../Eng/doc/new_features/new_features_doc.rst | 4 + .../Zh/doc/new_features/new_features_doc.rst | 5 +- docs/updates/2026-10.md | 8 ++ docs/updates/README.md | 3 +- .../callback/callback_function_executor.py | 10 +- je_auto_control/utils/observer/observer.py | 3 + .../utils/watchdog/popup_watchdog.py | 2 + .../headless/test_runtime_secret_callbacks.py | 123 ++++++++++++++++++ 15 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 test/unit_test/headless/test_runtime_secret_callbacks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ad7434434..6e643a000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Watchdog and observer rules, and callback-executor invocations, retain their + resolved-secret mask through detection, callbacks and final error logging. + Independent rules/calls isolate masks; existing return and failure semantics + remain unchanged. + - CLI/REST/MCP and background run owners retain resolved-secret masking through outer error handling and audit/history writes. CLI stderr and delayed report snapshots are sanitized; report snapshots omit opaque/cyclic values when the diff --git a/Progress.md b/Progress.md index 6514be368..2a7fcc337 100644 --- a/Progress.md +++ b/Progress.md @@ -222,7 +222,6 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 -- **回呼擁有者的秘密遮罩**(`TODO`):`utils/watchdog/popup_watchdog.py`、`utils/observer/observer.py`、`utils/callback/callback_function_executor.py` 的外層錯誤日誌仍缺少涵蓋完整回呼的遮罩範圍與對應回歸測試。 - **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index b2eb44dd2..c69c2d956 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Facade, command, CLI/MCP and GUI controls remain pending integration. -Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. +Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. ## Command-line interface diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 729038bdd..aa230cf6c 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -247,7 +247,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。门面、动作命令、CLI/MCP 与 GUI 控制仍待接入。 -`${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。 +`${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。 ## 命令行界面 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 4b7750071..18521f347 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -246,7 +246,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。門面、動作指令、CLI/MCP 與 GUI 控制仍待接入。 -`${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。 +`${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。 ## 命令列介面 diff --git a/architecture.md b/architecture.md index 62070e18f..8c50a307b 100644 --- a/architecture.md +++ b/architecture.md @@ -63,7 +63,9 @@ the mask alive through error handling and persistent diagnostics. A logger filter sanitizes records before handlers; audit, history and report snapshots mask text before storage. Joined local parallel/DAG workers share the mask; independent requests isolate it, and the owner clears it on exit. API outcomes -retain their values; CLI error output is sanitized. +retain their values; CLI error output is sanitized. Watchdog/observer rules and +callback-executor invocations own their complete diagnostic scope; nested +invocations reuse the enclosing run mask. **A. JSON action script (primary path)** diff --git a/architecture_explore.md b/architecture_explore.md index e752e7e79..6e6ce570b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,086 | -| 程式碼總行數 | 162,885 | +| 程式碼總行數 | 162,892 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -304,11 +304,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.2 框架基礎設施 -> 14 個套件、約 3,442 行。 +> 14 個套件、約 3,444 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `utils/callback/` | 209 | Observer 模式:`callback_executor` 以字串名觸發功能,執行後呼叫回呼 | +| `utils/callback/` | 211 | Observer 模式:`callback_executor` 以字串名觸發功能;trigger 與 callback 共用秘密遮罩至錯誤處理結束 | | `utils/config_bundle/` | 424 | 使用者設定的單檔匯出/匯入 | | `utils/critical_exit/` | 132 | 監看緊急停止鍵的守護執行緒,用於中止失控腳本 | | `utils/diagnostics/` | 330 | 跨子系統的「一切正常嗎」健檢,附 `python -m` 進入點 | @@ -325,20 +325,20 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.3 排程、觸發與背景監看 -> 11 個套件、約 4,377 行。 +> 11 個套件、約 4,382 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | | `utils/hotkey/` | 859 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | | `utils/idle_keepawake/` | 245 | 偵測使用者閒置時間並在無人值守執行期間阻止系統睡眠 | | `utils/lock_session/` | 166 | 鎖定工作站、等待解鎖並分類鎖定狀態轉換 | -| `utils/observer/` | 245 | 反應式畫面觀察者,在出現/消失/變化時觸發 | +| `utils/observer/` | 248 | 反應式畫面觀察者;每條規則涵蓋 predicate/handler 的秘密遮罩與隔離 | | `utils/recurrence/` | 470 | RFC 5545 重複規則解析與發生時間展開 | | `utils/scheduler/` | 514 | 間隔式與 cron 式的 action JSON 排程器 | | `utils/session_guard/` | 62 | 驅動輸入前先偵測工作階段是否已鎖定/非互動 | | `utils/triggers/` | 1,419 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | | `utils/voice/` | 99 | 語音指令路由:把辨識到的語句對應到 `AC_*` action list | -| `utils/watchdog/` | 208 | 背景彈窗/中斷看門狗,供無人值守自動化 | +| `utils/watchdog/` | 210 | 背景彈窗/中斷看門狗;每條規則涵蓋 matcher/action 與錯誤日誌的秘密遮罩 | | `utils/watcher/` | 90 | 無頭輪詢原語:滑鼠位置、像素顏色、log tail | ### 5.4.4 輸入模擬與動作品質 @@ -1102,6 +1102,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 859 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 696 | 59,512 | -| **總計** | **1,080** | **162,820** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 696 | 59,519 | +| **總計** | **1,080** | **162,827** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index edcbebdd7..d4b5dc784 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1354,6 +1354,10 @@ Python/JSON escaped forms are masked. Nested actions and joined local parallel/DAG workers share the run's mask; concurrent independent runs have separate masks. CLI/REST/MCP requests and scheduled, trigger, hotkey and email owners keep their mask through outer error handling and audit/history writes. +Independent watchdog/observer rules and callback-executor invocations keep a +mask through their matcher/predicate, trigger, callback and final error log. +Nested calls reuse the enclosing run's mask; independent rules and subsequent +invocations clear their values separately. CLI stderr is masked. Report records snapshot known values before the mask is cleared; cyclic and opaque diagnostic objects are replaced without calling arbitrary repr or deepcopy. Audit fields are sanitized before JSON encoding, diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index fbd9d1556..0a80598cf 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1257,7 +1257,10 @@ executor 執行期間,解析後的 ``${secrets.NAME}`` 值會在送到 handler 遮罩包含原始值與常見 Python/JSON 跳脫形式。巢狀動作及等候完成的本機 parallel/DAG 工作執行緒共用當次執行的遮罩;各個獨立執行彼此隔離。 CLI/REST/MCP 請求與排程、trigger、hotkey、email 擁有者會將遮罩保留到 -外層錯誤處理及 audit/history 寫入完成。CLI stderr 也會遮罩。報表紀錄在遮罩 +外層錯誤處理及 audit/history 寫入完成。獨立的 watchdog/observer 規則與 +callback executor 呼叫會將遮罩保留到 matcher/predicate、trigger、callback 及 +最後的錯誤日誌結束;巢狀呼叫沿用外層範圍,獨立規則與後續呼叫各自清除資料。 +CLI stderr 也會遮罩。報表紀錄在遮罩 清除前保存已處理的快照;循環與不透明診斷物件會替換為標記,不呼叫任意 repr 或 deepcopy。Audit 欄位在 JSON 編碼前處理,history 錯誤文字在 SQLite 寫入前處理。 即使失敗也會於擁有者結束時清除資料。API 結果、拋出的例外與 CLI 結果資料保留原始值。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 4085e727b..1dbdede01 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -421,3 +421,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: the full coverage run passes 11,660 tests with 69 skipped and 87.73% coverage; the mask module has 99.37% coverage. 18 new boundary cases and the 15 earlier mask cases pass. They cover both CLI entries, real loopback REST requests, MCP error results/audit, valid JSON with escaped secrets, six background run types, direct email-history execution, source-preserving report snapshots and opaque/cyclic data. Another 44 documentation, complexity, file-budget, exception and light-import checks pass. Strict leaf mypy and three-platform full typing pass; ruff/bandit pass and pylint scores 10.00/10. Strict English/Chinese Sphinx and wheel/source builds pass; the wheel matches all 14 changed production sources. Jeffrey_RPA's three facade/control suites pass 751 tests against this tree (five existing Discord deprecations). - **Files**: entry and background-owner scopes, `utils/logging/secret_scope.py`, MCP audit, run history, test-record snapshots, regression tests, three READMEs, Sphinx vault guide, changelog and measured architecture maps. - **Open items**: watchdog, observer and callback-executor owner scopes remain in `Progress.md`, alongside B1 surfaces/history linking. Native and paid-API acceptance remain deferred. + +## U-20261018-16 · 2026-10-18 · Retain callback-owner secret masks through final error logging · #done #security #callbacks + +- **What**: each independent watchdog/observer rule and callback-executor invocation owns a resolved-secret mask through detection, trigger execution, callbacks and final exception logging. Nested invocations reuse the enclosing run's mask; independent rules and later calls release their own values. Predicate/trigger resolutions remain protected when only the later callback fails. +- **Compatibility**: watchdog dismissal counts, observer transition events, callback trigger-result preservation and assertion/error contracts remain unchanged. Existing authorization and variable scopes are retained; no native detector or device was used for acceptance. +- **Validation**: 12 new cases cover matcher/action and predicate/handler errors, trigger/callback error boundaries, result preservation, cross-phase retention, direct observer dispatch and independent rule/call isolation. The complete related selection passes 191 tests, including deferred RBAC and flow control. Another 44 documentation, complexity, file-budget, exception and light-import gates pass. Whole-package three-platform mypy, ruff and bandit pass; strict English/Chinese Sphinx and wheel/source builds pass. The wheel matches all three changed owner modules. +- **Files**: `utils/{watchdog/popup_watchdog,observer/observer,callback/callback_function_executor}.py`, regression tests, three READMEs, Sphinx vault guide, changelog and measured architecture maps. +- **Open items**: B1 facade/command/CLI/MCP/GUI surfaces and run-history linking remain in `Progress.md`; native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index 831e0f70e..3e76b2755 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-16 | 2026-10-18 | Retain callback-owner secret masks through final error logging | #done #security #callbacks | [2026-10](2026-10.md) | | U-20261018-15 | 2026-10-18 | Keep secret masking alive through outer errors and diagnostic writes | #done #security #diagnostics | [2026-10](2026-10.md) | | U-20261018-14 | 2026-10-18 | Isolate the legacy CLI dispatch boundary without changing flags | #done #cli #refactor | [2026-10](2026-10.md) | | U-20261018-13 | 2026-10-18 | Mask resolved vault values during executor application logging | #done #security #logging | [2026-10](2026-10.md) | @@ -378,7 +379,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 42 | +| [2026-10.md](2026-10.md) | 2026-10 | 43 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/callback/callback_function_executor.py b/je_auto_control/utils/callback/callback_function_executor.py index 21ca563a1..f627ce748 100644 --- a/je_auto_control/utils/callback/callback_function_executor.py +++ b/je_auto_control/utils/callback/callback_function_executor.py @@ -9,6 +9,7 @@ AutoControlAssertionException, CallbackExecutorException, ) from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope # executor from je_auto_control.utils.executor.action_executor import execute_action, execute_files from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS @@ -138,6 +139,7 @@ def __init__(self): "AC_execute_process": start_exe, } + @secret_log_scope() def callback_function( self, trigger_function_name: str, @@ -147,7 +149,7 @@ def callback_function( **kwargs ) -> Any: """ - Execute a trigger function and then call a callback function + Execute trigger and callback with one run-local diagnostic mask 執行指定的 trigger_function,並在完成後呼叫 callback_function :param trigger_function_name: 要觸發的函式名稱 (必須存在於 event_dict) @@ -168,7 +170,7 @@ def callback_function( autocontrol_logger.error("callback_function config error: %r", error) return None - # Run the trigger in its own scope: a trigger failure (including the + # Guard the trigger separately: a trigger failure (including the # framework family — mouse / image / assertion errors) returns None and # is never confused with a later callback failure. # Anything the trigger raises (OSError from a file command, a @@ -181,7 +183,7 @@ def callback_function( autocontrol_logger.error("callback_function trigger failed: %r", error) return None - # Run the callback in its own scope: a callback failure must not discard + # Guard the callback separately: a callback failure must not discard # the already-computed trigger result. try: self._invoke_callback( @@ -206,4 +208,4 @@ def _invoke_callback(callback_function: Callable, # === 全域 Callback Executor 實例 Global Instance === callback_executor = CallbackFunctionExecutor() -package_manager.callback_executor = callback_executor \ No newline at end of file +package_manager.callback_executor = callback_executor diff --git a/je_auto_control/utils/observer/observer.py b/je_auto_control/utils/observer/observer.py index bdefceefd..067f0d8fd 100644 --- a/je_auto_control/utils/observer/observer.py +++ b/je_auto_control/utils/observer/observer.py @@ -19,6 +19,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.run_outcome import run_counting_failures from je_auto_control.utils.timeouts import clamp_poll_interval @@ -136,6 +137,7 @@ def poll_once(self) -> List[Dict[str, Any]]: return [event for event in (self._evaluate(rule) for rule in rules) if event is not None] + @secret_log_scope() def _evaluate(self, rule: WatchRule) -> Optional[Dict[str, Any]]: # The transition is inside the guard too: a predicate returning, say, # a numpy array raised from bool() there. One rule may not stop the @@ -161,6 +163,7 @@ def _evaluate(self, rule: WatchRule) -> Optional[Dict[str, Any]]: self._events.append(record) return record + @secret_log_scope() def _fire(self, rule: WatchRule, event: str, value: Any) -> None: try: with rule._authorization.scope(): diff --git a/je_auto_control/utils/watchdog/popup_watchdog.py b/je_auto_control/utils/watchdog/popup_watchdog.py index 613664abe..621b754d5 100644 --- a/je_auto_control/utils/watchdog/popup_watchdog.py +++ b/je_auto_control/utils/watchdog/popup_watchdog.py @@ -21,6 +21,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.run_outcome import run_counting_failures from je_auto_control.utils.timeouts import clamp_poll_interval @@ -141,6 +142,7 @@ def check_once(self) -> int: dismissed += 1 return dismissed + @secret_log_scope() def _apply(self, rule: WatchdogRule, authorization: Optional[DeferredAuthorization] = None) -> bool: bound = authorization if authorization is not None else DeferredAuthorization.capture() diff --git a/test/unit_test/headless/test_runtime_secret_callbacks.py b/test/unit_test/headless/test_runtime_secret_callbacks.py new file mode 100644 index 000000000..5bb720c41 --- /dev/null +++ b/test/unit_test/headless/test_runtime_secret_callbacks.py @@ -0,0 +1,123 @@ +"""Callback owners mask errors through their final logging without changing results.""" +import io +import logging + +import pytest + +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.secrets import default_secret_manager + +FAKE = "fake-callback-private-value" +PROBE = "AC_callback_secret_probe" + + +@pytest.fixture +def output(monkeypatch): + stream = io.StringIO() + handler = logging.StreamHandler(stream) + autocontrol_logger.addHandler(handler) + monkeypatch.setattr(default_secret_manager, "get", lambda name: FAKE) + monkeypatch.setitem(executor.event_dict, PROBE, lambda value: value) + try: + yield stream + finally: + autocontrol_logger.removeHandler(handler) + handler.close() + + +def _resolve(): + record = executor.execute_action([[PROBE, {"value": "${secrets.LOGIN}"}]]) + return next(iter(record.values())) + + +def _fail(*_args): + value = _resolve() + raise RuntimeError("private callback failure: " + value) + + +@pytest.mark.parametrize("phase", ["matcher", "handler"]) +def test_watchdog_masks_both_detection_and_action_failures(output, phase): + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + owner = PopupWatchdog() + owner.add_rule(WatchdogRule("private", _fail if phase == "matcher" else lambda: True, _fail)) + assert owner.check_once() == 0 + assert FAKE not in output.getvalue() and "private callback failure: ***" in output.getvalue() + + +@pytest.mark.parametrize("phase", ["predicate", "handler"]) +def test_observer_masks_predicate_and_handler_failures(output, phase): + from je_auto_control.utils.observer.observer import ScreenObserver + owner = ScreenObserver() + owner.add("private", _fail if phase == "predicate" else lambda: True, _fail) + events = owner.poll_once() + assert len(events) == (0 if phase == "predicate" else 1) + assert FAKE not in output.getvalue() and "private callback failure: ***" in output.getvalue() + + +@pytest.mark.parametrize("phase", ["trigger", "callback"]) +def test_callback_executor_retains_the_trigger_mask_through_callback_logging(output, phase): + from je_auto_control.utils.callback.callback_function_executor import CallbackFunctionExecutor + owner = CallbackFunctionExecutor() + owner.event_dict["AC_probe"] = _fail if phase == "trigger" else _resolve + def callback(): + raise RuntimeError("private callback failure: " + FAKE) + result = owner.callback_function("AC_probe", callback) + assert result == (None if phase == "trigger" else FAKE) + assert FAKE not in output.getvalue() and "private callback failure: ***" in output.getvalue() + + +def test_callback_calls_do_not_retain_an_earlier_invocations_mask(output): + from je_auto_control.utils.callback.callback_function_executor import CallbackFunctionExecutor + owner = CallbackFunctionExecutor() + owner.event_dict["AC_resolve"] = _resolve + owner.event_dict["AC_literal"] = lambda: FAKE + assert owner.callback_function("AC_resolve", lambda: None) == FAKE + assert FAKE not in output.getvalue() + assert owner.callback_function("AC_literal", lambda: autocontrol_logger.info("unrelated: %s", FAKE)) == FAKE + assert "unrelated: " + FAKE in output.getvalue() + + +@pytest.mark.parametrize("kind", ["watchdog", "observer"]) +def test_independent_rules_have_independent_masks(output, kind): + def unrelated(*_args): + autocontrol_logger.info("unrelated rule: %s", FAKE) + return False + if kind == "watchdog": + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + owner = PopupWatchdog() + owner.add_rule(WatchdogRule("private", _fail, lambda: None)) + owner.add_rule(WatchdogRule("unrelated", unrelated, lambda: None)) + owner.check_once() + else: + from je_auto_control.utils.observer.observer import ScreenObserver + owner = ScreenObserver() + owner.add("private", _fail, lambda *_args: None) + owner.add("unrelated", unrelated, lambda *_args: None) + owner.poll_once() + assert "private callback failure: ***" in output.getvalue() + assert "unrelated rule: " + FAKE in output.getvalue() + + +def test_direct_observer_callback_boundary_also_masks_its_own_error(output): + from je_auto_control.utils.observer.observer import ScreenObserver, WatchRule + owner = ScreenObserver() + owner._fire(WatchRule("private", lambda: True, _fail), "appear", True) + assert FAKE not in output.getvalue() and "private callback failure: ***" in output.getvalue() + + +@pytest.mark.parametrize("kind", ["watchdog", "observer"]) +def test_detection_and_callback_share_the_same_mask_until_diagnostics_finish(output, kind): + def later_failure(*_args): + raise RuntimeError("later callback failure: " + FAKE) + if kind == "watchdog": + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + owner = PopupWatchdog() + owner.add_rule(WatchdogRule("private", lambda: bool(_resolve()), later_failure)) + assert owner.check_once() == 0 + else: + from je_auto_control.utils.observer.observer import ScreenObserver + owner = ScreenObserver() + owner.add("private", _resolve, later_failure) + assert len(owner.poll_once()) == 1 + assert FAKE not in output.getvalue() and "later callback failure: ***" in output.getvalue() From b7de6e57d72476c252ff0e04b3a4ca894a22ab6e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 23:26:23 +0800 Subject: [PATCH 016/294] Preserve journal provenance across existing history owners --- CHANGELOG.md | 7 + Progress.md | 3 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 7 +- architecture_explore.md | 42 ++-- .../Eng/doc/new_features/new_features_doc.rst | 39 +++- .../Zh/doc/new_features/new_features_doc.rst | 37 ++- docs/updates/2026-10.md | 15 ++ docs/updates/README.md | 4 +- .../utils/action_journal/capture.py | 4 + je_auto_control/utils/dag/runner.py | 4 +- .../utils/executor/flow_control.py | 4 +- je_auto_control/utils/hotkey/hotkey_daemon.py | 4 +- je_auto_control/utils/run_history/__init__.py | 4 +- je_auto_control/utils/run_history/context.py | 36 +++ .../utils/run_history/history_store.py | 65 +++++- je_auto_control/utils/scheduler/scheduler.py | 3 +- je_auto_control/utils/sqlite_support.py | 3 +- .../utils/triggers/email_trigger.py | 3 +- .../utils/triggers/trigger_engine.py | 3 +- .../utils/triggers/webhook_server.py | 3 +- .../headless/test_journal_history_links.py | 218 ++++++++++++++++++ 24 files changed, 468 insertions(+), 46 deletions(-) create mode 100644 je_auto_control/utils/run_history/context.py create mode 100644 test/unit_test/headless/test_journal_history_links.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e643a000..62f8a942b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Explicit action journals now link to an enclosing existing run-history row, + including local parallel/DAG captures. Links are idempotent and non-owning; + clearing/pruning history keeps external journal files. Plain captures create + no implicit history row. Legacy history databases migrate without losing rows. +- Failed run-history initialization closes its connection before propagating + the failure, and no partially initialized connection remains cached. + - Watchdog and observer rules, and callback-executor invocations, retain their resolved-secret mask through detection, callbacks and final error logging. Independent rules/calls isolate masks; existing return and failure semantics diff --git a/Progress.md b/Progress.md index 2a7fcc337..66a7526a0 100644 --- a/Progress.md +++ b/Progress.md @@ -14,7 +14,7 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的門面/動作指令/CLI/MCP/GUI 介面與 run-history 連結、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的門面/動作指令/CLI/MCP/GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 @@ -222,6 +222,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 +- **背景執行的硬中止狀態**(`TODO`):`utils/{scheduler,triggers,hotkey}/` 的 run owner 只在 `Exception` 設錯誤狀態,`KeyboardInterrupt`/`SystemExit` 等硬中止仍會由 finally 寫成 history `ok`;需改為失敗/中止結果並保留原始控制流程,且中止後不再擷取錯誤截圖。 - **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index c69c2d956..433d5020b 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. --- -Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Facade, command, CLI/MCP and GUI controls remain pending integration. +Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. Facade, command, CLI/MCP/GUI and manual-run history controls remain pending integration. Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index aa230cf6c..a875da9c4 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -245,7 +245,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。门面、动作命令、CLI/MCP 与 GUI 控制仍待接入。 +结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。门面、动作命令、CLI/MCP/GUI 与手动执行的 history 控制仍待接入。 `${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 18521f347..6d7b075ad 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -244,7 +244,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。門面、動作指令、CLI/MCP 與 GUI 控制仍待接入。 +結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。門面、動作指令、CLI/MCP/GUI 與手動執行的 history 控制仍待接入。 `${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。 diff --git a/architecture.md b/architecture.md index 8c50a307b..9576aba18 100644 --- a/architecture.md +++ b/architecture.md @@ -125,7 +125,12 @@ their step parent and local parallel/DAG workers rebind the same run metadata. `execute_action_with_journal` opens a fresh variable scope and returns a typed run summary. Capture starts are durable before effects; failed actions, handled loop signals, cancellation and dry-runs receive distinct completion statuses. -Facade/command/CLI/MCP/GUI surfaces and run-history linking remain pending. +`utils/run_history/context.py` binds an existing history row to execution. +Scheduler/trigger/hotkey/webhook/email owners bind their own row; local parallel +and DAG workers carry it explicitly. `journal_scope` attaches an idempotent +non-owning path/run-ID link before dispatch. `run_journals` cascades metadata +when rows are pruned/cleared; external journal files are never deleted by history. +Facade/command/CLI/MCP/GUI and manual-run history controls remain pending. **C. Record → edit → generate code** diff --git a/architecture_explore.md b/architecture_explore.md index 6e6ce570b..f62876fa9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,8 +19,8 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,086 | -| 程式碼總行數 | 162,892 | +| Python 模組總數(含周邊子專案) | 1,087 | +| 程式碼總行數 | 162,994 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -162,7 +162,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `je_auto_control/utils/cli_output.py` | 44 | 本套件命令列工具與 stdio 伺服器的標準串流:不論碼頁一律 UTF-8。 | | `je_auto_control/utils/deprecation.py` | 35 | 公開 API 的一致性棄用警告。 | | `je_auto_control/utils/http_headers.py` | 189 | 本套件各伺服器共用的防禦式輔助:標頭、內文、回應與日誌。 | -| `je_auto_control/utils/sqlite_support.py` | 112 | 選用標準函式庫 `sqlite3` 的取用點:`require_sqlite3()`/`sqlite3_available()`/`SQLITE_ERRORS`。十個以 SQLite 存放狀態的子系統都經由這裡,所以 FreeBSD 這種把 `sqlite3` 另外包成 `databases/py-sqlite3` 的 Python 仍然 import 得起門面。 | +| `je_auto_control/utils/sqlite_support.py` | 113 | 選用標準函式庫 `sqlite3` 的取用點:`require_sqlite3()`/`sqlite3_available()`/`SQLITE_ERRORS`。十個以 SQLite 存放狀態的子系統都經由這裡,所以 FreeBSD 這種把 `sqlite3` 另外包成 `databases/py-sqlite3` 的 Python 仍然 import 得起門面。 | | `je_auto_control/utils/timeouts.py` | 33 | 把使用者給的逾時換成截止時間:`deadline_after()` 拒絕 NaN(`json` 接受它,而 `clock() >= NaN` 永遠不成立,輪詢迴圈會永遠跑下去),負值與無限大維持原意;`clamp_poll_interval()` 把背景迴圈的輪詢間隔夾在 0.05 秒到 1 小時之間(`Event.wait(inf)` 在 Windows 會丟 `OverflowError`)。 | ### 5.2 wrapper 抽象層 @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,844 行。 +> 25 個套件、約 15,852 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -280,11 +280,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/action_signing/` | 716 | action 檔 HMAC-SHA256 簽章與 Fernet 加密,`execute_files` 會強制驗簽 | | `utils/checkpoint/` | 129 | 流程檢查點與續跑,讓長 action list 具持久性 | | `utils/codegen/` | 294 | 由 action list 產生可執行的 pytest / python / robot 測試碼 | -| `utils/dag/` | 564 | 跨主機 DAG 編排器(圖模型 + runner) | +| `utils/dag/` | 566 | 跨主機 DAG 編排器(圖模型 + runner) | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | -| `utils/action_journal/` | 585 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | -| `utils/executor/` | 9,682 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/action_journal/` | 589 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | +| `utils/executor/` | 9,684 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -325,18 +325,18 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.3 排程、觸發與背景監看 -> 11 個套件、約 4,382 行。 +> 11 個套件、約 4,388 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `utils/hotkey/` | 859 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | +| `utils/hotkey/` | 861 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | | `utils/idle_keepawake/` | 245 | 偵測使用者閒置時間並在無人值守執行期間阻止系統睡眠 | | `utils/lock_session/` | 166 | 鎖定工作站、等待解鎖並分類鎖定狀態轉換 | | `utils/observer/` | 248 | 反應式畫面觀察者;每條規則涵蓋 predicate/handler 的秘密遮罩與隔離 | | `utils/recurrence/` | 470 | RFC 5545 重複規則解析與發生時間展開 | -| `utils/scheduler/` | 514 | 間隔式與 cron 式的 action JSON 排程器 | +| `utils/scheduler/` | 515 | 間隔式與 cron 式的 action JSON 排程器 | | `utils/session_guard/` | 62 | 驅動輸入前先偵測工作階段是否已鎖定/非互動 | -| `utils/triggers/` | 1,419 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | +| `utils/triggers/` | 1,422 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | | `utils/voice/` | 99 | 語音指令路由:把辨識到的語句對應到 `AC_*` action list | | `utils/watchdog/` | 210 | 背景彈窗/中斷看門狗;每條規則涵蓋 matcher/action 與錯誤日誌的秘密遮罩 | | `utils/watcher/` | 90 | 無頭輪詢原語:滑鼠位置、像素顏色、log tail | @@ -559,7 +559,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.12 報表、可觀測性與測試治理 -> 34 個套件、約 7,728 行。 +> 34 個套件、約 7,815 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -584,7 +584,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/profiler/` | 451 | 逐動作效能剖析器 + 資源剖析器 | | `utils/quarantine/` | 204 | 易碎測試隔離區,讓套件執行器跳過已知不穩定案例 | | `utils/run_diff/` | 143 | 兩次執行軌跡的差異(LCS 對齊:新增/移除/狀態翻轉/退化) | -| `utils/run_history/` | 461 | 執行歷史儲存與產出物管理 | +| `utils/run_history/` | 548 | 執行歷史、產出物與非擁有式 journal 連結;既有 run 的 context 傳入本機 workers,清理 history 不刪日誌檔 | | `utils/sarif/` | 187 | 以 SARIF 2.1.0 匯出發現項,供 GitHub/Azure code scanning | | `utils/slo/` | 115 | SLO 評估:SLI、錯誤預算與多視窗燃燒率告警 | | `utils/smoothing/` | 67 | 數列移動平均平滑 | @@ -697,12 +697,12 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,682 行)— 執行核心 +#### `utils/executor/`(9,684 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | | `action_executor.py` | 8,445 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | -| `flow_control.py` | 662 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | +| `flow_control.py` | 664 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | | `action_schema.py` | 159 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | | `action_redaction.py` | 86 | 記錄與紀錄鍵用的遮蔽:`AC_secret_*` 的參數(金庫通行碼、機密值)在寫進 log、當成結果紀錄的鍵之前換成 `***`,巢狀在區塊指令裡的也一樣。 | @@ -856,7 +856,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `action_lint/` | `linter.py`、`schema.py`、`__main__.py`(CI 使用) | | `time_travel/` | `controller.py`、`player.py` | | `dag/` | `graph.py`、`runner.py` | -| `run_history/` | `history_store.py`、`artifact_manager.py`、`run_outcome.py`(排程/觸發/熱鍵執行有動作失敗時判為錯誤) | +| `run_history/` | `history_store.py`、`context.py`(非擁有式 journal 連結與 worker 擁有者)、`artifact_manager.py`、`run_outcome.py`(動作失敗判為錯誤) | | `self_healing/` | `locator.py`、`heal_log.py` | | `ab_locator/` | `runner.py`、`store.py` | | `cost_telemetry/` | `pricing.py`、`store.py` | @@ -1085,7 +1085,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `gui/` | 100 | 28,788 | | `utils/mcp_server/` | 37 | 19,168 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,682 | +| `utils/executor/` | 8 | 9,684 | | `utils/usb/` | 17 | 4,816 | | `je_auto_control/`(頂層 3 檔) | 3 | 2,470 | | `utils/accessibility/` | 14 | 3,143 | @@ -1095,13 +1095,13 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `utils/agent/` | 9 | 2,142 | | `linux_with_x11/` | 19 | 1,281 | | `linux_wayland/` | 17 | 2,921 | -| `utils/triggers/` | 4 | 1,419 | +| `utils/triggers/` | 4 | 1,422 | | `utils/ocr/` | 10 | 1,381 | | `utils/usbip/` | 5 | 1,008 | | `utils/assertion/` | 3 | 887 | | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | -| `utils/hotkey/` | 7 | 859 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 696 | 59,519 | -| **總計** | **1,080** | **162,827** | +| `utils/hotkey/` | 7 | 861 | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 697 | 59,614 | +| **總計** | **1,081** | **162,929** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index d4b5dc784..6538fba56 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1475,7 +1475,7 @@ Structured action journal storage (integration pending) ======================================================= B1 provides a storage foundation under ``je_auto_control.utils.action_journal``; -facade and command exports, CLI/MCP/GUI controls and run-history linking remain +facade and command exports, CLI/MCP/GUI and manual-run history controls remain pending integration. Applications can explicitly write schema-v1 snapshots:: import time @@ -1530,3 +1530,40 @@ Soft/strict failures, cancellation and ``dry_run`` stay distinct. ``ok`` reports dispatch completion; domain verification is a separate check. A process killed mid-command retains its durable incomplete start. Completion-storage failure preserves an original cancellation/error and leaves the start incomplete. + + +History provenance links +------------------------ + +Scheduler, trigger, hotkey, webhook and email owners bind their existing history +row during execution. Explicit ``journal_scope`` capture automatically attaches +a canonical journal path and journal run ID to that row before dispatch. Local +parallel and default DAG workers carry this owner when they open captures too. +Multiple captures can link to one history row; duplicate links are idempotent. +Plain capture without an owner does not create a history row or open a history +database. For an explicitly managed manual row:: + + from pathlib import Path + from je_auto_control.utils.action_journal import ActionJournal, journal_scope + from je_auto_control.utils.executor.action_executor import executor + from je_auto_control.utils.run_history.context import HistoryBinding, bound_history + from je_auto_control.utils.run_history.history_store import HistoryStore, SOURCE_MANUAL, STATUS_OK + + history = HistoryStore("history.sqlite") + try: + row_id = history.start_run(SOURCE_MANUAL, "example", "actions.json") + with bound_history(HistoryBinding(history, row_id)): + with journal_scope(ActionJournal(Path("run.jsonl")), run_id="example"): + executor.execute_action([["AC_set_var", {"name": "count", "value": 1}]]) + history.finish_run(row_id, STATUS_OK) + links = history.journals_for_run(row_id) + finally: + history.close() + +``HistoryStore.attach_journal`` stores metadata only: it does not open, validate, +copy or take ownership of the target file. ``journals_for_run`` returns immutable +``JournalLink`` values with ``run_id``, ``journal_path`` and ``journal_run_id``. +Clearing/pruning history removes links and preserves journal files. Existing +history schemas migrate on opening; original rows and screenshot links survive. +An unknown bound history row refuses capture before device effects. GUI and +manual-run convenience wiring remain pending integration. diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index 0a80598cf..b7b8d7965 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1372,7 +1372,7 @@ GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher ==================================== B1 的儲存與明確擷取位於 ``je_auto_control.utils.action_journal``;門面與動作指令匯出、 -CLI/MCP/GUI 控制、run-history 連結仍待接入。應用程式可明確寫入 schema-v1 快照:: +CLI/MCP/GUI 與手動執行的 history 控制仍待接入。應用程式可明確寫入 schema-v1 快照:: import time from dataclasses import replace @@ -1418,3 +1418,38 @@ CLI/MCP/GUI 控制、run-history 連結仍待接入。應用程式可明確 一般/嚴格失敗、取消與 ``dry_run`` 分別記錄;``ok`` 表示 dispatch 完成,業務結果需另外驗證。 行程在動作中被結束時,保留已落盤的未完成起始紀錄。 完成紀錄無法寫入時會保留原本的取消/錯誤,起始紀錄維持未完成。 + + +執行歷史的日誌來源連結 +------------------------ + +排程、trigger、hotkey、webhook 與 email 擁有者會在執行期間綁定既有 history 列。 +明確的 ``journal_scope`` 擷取會在 dispatch 前附加 canonical journal 路徑與 journal +run ID;本機 parallel 與預設 DAG worker 另開擷取時也保留擁有者。 +同一列可連結多份 journal,重複附加相同連結不會增加資料。 +未綁定擁有者的普通擷取不會建立 history 列或開啟 history 資料庫。 +手動管理既有列的用法:: + + from pathlib import Path + from je_auto_control.utils.action_journal import ActionJournal, journal_scope + from je_auto_control.utils.executor.action_executor import executor + from je_auto_control.utils.run_history.context import HistoryBinding, bound_history + from je_auto_control.utils.run_history.history_store import HistoryStore, SOURCE_MANUAL, STATUS_OK + + history = HistoryStore("history.sqlite") + try: + row_id = history.start_run(SOURCE_MANUAL, "example", "actions.json") + with bound_history(HistoryBinding(history, row_id)): + with journal_scope(ActionJournal(Path("run.jsonl")), run_id="example"): + executor.execute_action([["AC_set_var", {"name": "count", "value": 1}]]) + history.finish_run(row_id, STATUS_OK) + links = history.journals_for_run(row_id) + finally: + history.close() + +``HistoryStore.attach_journal`` 只保存中繼資料,不開啟、驗證、複製或擁有目標檔案。 +``journals_for_run`` 回傳不可變的 ``JournalLink``,欄位為 ``run_id``、 +``journal_path`` 與 ``journal_run_id``。清空/裁剪 history 只移除連結,保留 journal 檔。 +舊 history schema 會在開啟時遷移,保留原有列與截圖連結。 +綁定不存在的 history 列時,會在裝置動作發生前拒絕擷取。 +GUI 與手動執行的便利接線仍待整合。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 1dbdede01..8f76f1fa0 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -429,3 +429,18 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: 12 new cases cover matcher/action and predicate/handler errors, trigger/callback error boundaries, result preservation, cross-phase retention, direct observer dispatch and independent rule/call isolation. The complete related selection passes 191 tests, including deferred RBAC and flow control. Another 44 documentation, complexity, file-budget, exception and light-import gates pass. Whole-package three-platform mypy, ruff and bandit pass; strict English/Chinese Sphinx and wheel/source builds pass. The wheel matches all three changed owner modules. - **Files**: `utils/{watchdog/popup_watchdog,observer/observer,callback/callback_function_executor}.py`, regression tests, three READMEs, Sphinx vault guide, changelog and measured architecture maps. - **Open items**: B1 facade/command/CLI/MCP/GUI surfaces and run-history linking remain in `Progress.md`; native and paid-API acceptance remain deferred. + +## U-20261018-17 · 2026-10-18 · Release SQLite connections after failed history initialization · #incident #history #storage + +- **Finding**: a controlled SQLite schema failure showed that `HistoryStore._connection` left its newly opened connection unclosed. A later migration failure could also leave a partially initialized connection cached. +- **Correction**: close on failed initialization and cache only after schema creation/migration succeeds; propagate the original control flow. Added the optional SQLite module's return type and explicit connection/artifact iterator types so the history implementation and its context pass strict mypy without expanding exemptions. +- **Validation**: the new failure-injection case first failed on the unclosed connection and passes after the fix; the 229-test history/journal/deferred/flow selection and 14 optional-SQLite/store audit cases pass. Four modules pass strict mypy and the full three-platform typing gate passes. +- **Files**: `utils/run_history/history_store.py`, `utils/sqlite_support.py`, failure-injection test and changelog. + +## U-20261018-18 · 2026-10-18 · Link explicit journals to existing run-history owners · #done #journal #history + +- **What**: immutable `HistoryBinding` explicitly carries an existing store/row across nested execution and joined local parallel/DAG workers. Scheduler, trigger, hotkey, webhook and email dispatch bind their existing row. `journal_scope` attaches canonical path/run-ID provenance before effects; missing owners refuse capture. Plain captures open no history database or row. +- **Storage**: immutable `JournalLink` values come from the idempotent `run_journals` relation. Legacy schemas and rows migrate in place. History clear/prune cascades metadata while preserving every external journal file; links never join the screenshot-artifact deletion list. Multiple journals can point to one history row. +- **Validation**: the full suite passes 11,689 tests with 69 skipped and 87.75% coverage. 17 new cases cover multiple/idempotent links, missing owners and invalid metadata, legacy migration/reopen, clear/prune file preservation, nested/restored/cleared bindings, both local worker paths, all five background owners and initialization failure. The related selection passes 229 tests with 92.95% combined scoped history/context coverage; context coverage is complete. Another 14 optional-SQLite/store cases and 44 documentation, budget, exception and light-import gates pass. Four touched modules pass strict mypy and the full three-platform typing gate passes; ruff and bandit pass. New context code scores 10.00/10 in pylint; the modified legacy modules retain only the existing RunRecord attribute-count and path-docstring findings at the repository's duplication threshold. Strict English/Chinese Sphinx and wheel/source builds pass; all 12 changed production sources match the wheel. +- **Files**: `utils/run_history/context.py`, history store/package exports, journal capture, parallel/DAG bindings, five background owners, SQLite typing, tests, three READMEs, Sphinx journal reference, changelog and measured architecture maps. +- **Open items**: B1 facade/command/CLI/MCP/GUI, manual-run history controls, hard-cancellation history status and journal browsing remain in `Progress.md`. A controlled scheduler probe confirmed a cancelled journal linked to an incorrectly successful legacy history row; this remains a separate follow-up. Native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3e76b2755..e6a5410d1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,8 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-18 | 2026-10-18 | Link explicit journals to existing run-history owners | #done #journal #history | [2026-10](2026-10.md) | +| U-20261018-17 | 2026-10-18 | Release SQLite connections after failed history initialization | #incident #history #storage | [2026-10](2026-10.md) | | U-20261018-16 | 2026-10-18 | Retain callback-owner secret masks through final error logging | #done #security #callbacks | [2026-10](2026-10.md) | | U-20261018-15 | 2026-10-18 | Keep secret masking alive through outer errors and diagnostic writes | #done #security #diagnostics | [2026-10](2026-10.md) | | U-20261018-14 | 2026-10-18 | Isolate the legacy CLI dispatch boundary without changing flags | #done #cli #refactor | [2026-10](2026-10.md) | @@ -379,7 +381,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 43 | +| [2026-10.md](2026-10.md) | 2026-10 | 45 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/action_journal/capture.py b/je_auto_control/utils/action_journal/capture.py index 0d735ae87..6ad546e70 100644 --- a/je_auto_control/utils/action_journal/capture.py +++ b/je_auto_control/utils/action_journal/capture.py @@ -16,6 +16,7 @@ from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.path_guard.path_guard import validate_path from je_auto_control.utils.script_vars.execution import execution_scope +from je_auto_control.utils.run_history.context import current_history @dataclass(frozen=True) @@ -73,6 +74,9 @@ def journal_scope(journal: ActionJournal, *, run_id: Optional[str] = None, if not isinstance(identity, str) or not identity: raise ActionJournalError("journal run ID must be a non-empty string") binding = JournalBinding(journal, identity, device_id=device_id, session_id=session_id) + history = current_history() + if history is not None and not history.store.attach_journal(history.run_id, str(journal.path.resolve()), identity): + raise ActionJournalError("cannot attach journal to a missing history run") with bound_journal(binding): yield binding diff --git a/je_auto_control/utils/dag/runner.py b/je_auto_control/utils/dag/runner.py index 2e61c3593..393a4fbff 100644 --- a/je_auto_control/utils/dag/runner.py +++ b/je_auto_control/utils/dag/runner.py @@ -304,13 +304,15 @@ def _current_run_local_runner() -> NodeRunner: from je_auto_control.utils.script_vars.execution import bound_scope from je_auto_control.utils.action_journal.capture import bound_journal, current_journal from je_auto_control.utils.logging.secret_scope import bound_secret_scope, current_secret_scope + from je_auto_control.utils.run_history.context import bound_history, current_history runner = _running_executor() scope = runner.variables journal = current_journal() secret_scope = current_secret_scope() + history = current_history() def run(node: DagNode, definition: DagDefinition) -> Any: - with bound_scope(scope), bound_journal(journal), bound_secret_scope(secret_scope): + with bound_scope(scope), bound_journal(journal), bound_secret_scope(secret_scope), bound_history(history): return _default_local_runner(node, definition, runner) return run diff --git a/je_auto_control/utils/executor/flow_control.py b/je_auto_control/utils/executor/flow_control.py index f3e8c0c3c..0ab46230d 100644 --- a/je_auto_control/utils/executor/flow_control.py +++ b/je_auto_control/utils/executor/flow_control.py @@ -25,6 +25,7 @@ from je_auto_control.utils.rbac.authorization import authorization_scope, current_authorization from je_auto_control.utils.action_journal.capture import bound_journal, current_journal from je_auto_control.utils.logging.secret_scope import bound_secret_scope, current_secret_scope +from je_auto_control.utils.run_history.context import bound_history, current_history from je_auto_control.wrapper.auto_control_image import locate_image_center from je_auto_control.wrapper.auto_control_screen import get_pixel from je_auto_control.utils.timeouts import deadline_after @@ -473,6 +474,7 @@ def __init__(self, executor: Any, branches: list) -> None: self._caller = current_authorization() self._journal = current_journal() self._secret_scope = current_secret_scope() + self._history = current_history() self.results: list = [None] * len(branches) self._failures: list = [0] * len(branches) self._errors: Dict[int, str] = {} @@ -503,7 +505,7 @@ def run_branch(self, index: int, branch: Any) -> None: self._module.reset_recorded_failures() try: with (authorization_scope(self._caller), bound_journal(self._journal), - bound_secret_scope(self._secret_scope)): + bound_secret_scope(self._secret_scope), bound_history(self._history)): self.results[index] = self._branch_executor().execute_action( branch, raise_on_error=self._strict, _validated=True) self._failures[index] = self._module.recorded_failures() diff --git a/je_auto_control/utils/hotkey/hotkey_daemon.py b/je_auto_control/utils/hotkey/hotkey_daemon.py index 2850517b0..becbc0881 100644 --- a/je_auto_control/utils/hotkey/hotkey_daemon.py +++ b/je_auto_control/utils/hotkey/hotkey_daemon.py @@ -20,6 +20,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.context import HistoryBinding, bound_history from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( @@ -223,8 +224,9 @@ def _fire_binding(self, binding_id: str) -> None: run_id = _start_history(match) status = STATUS_OK error_text: Optional[str] = None + history = HistoryBinding(default_history_store, run_id) if run_id is not None else None try: - with match._authorization.scope(): + with match._authorization.scope(), bound_history(history): actions = read_executable_action_json(match.script_path) run_counting_failures(lambda: self._execute(actions)) except Exception as error: # noqa: BLE001 # reason: this runs on the backend's listener thread; any escape ends every hotkey diff --git a/je_auto_control/utils/run_history/__init__.py b/je_auto_control/utils/run_history/__init__.py index 67825bd01..fc26b7bae 100644 --- a/je_auto_control/utils/run_history/__init__.py +++ b/je_auto_control/utils/run_history/__init__.py @@ -1,6 +1,6 @@ """Run history package.""" from je_auto_control.utils.run_history.history_store import ( - HistoryStore, RunRecord, default_history_store, + HistoryStore, JournalLink, RunRecord, default_history_store, ) -__all__ = ["HistoryStore", "RunRecord", "default_history_store"] +__all__ = ["HistoryStore", "JournalLink", "RunRecord", "default_history_store"] diff --git a/je_auto_control/utils/run_history/context.py b/je_auto_control/utils/run_history/context.py new file mode 100644 index 000000000..41b2430b1 --- /dev/null +++ b/je_auto_control/utils/run_history/context.py @@ -0,0 +1,36 @@ +"""Explicit history ownership for nested execution and joined workers.""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from typing import TYPE_CHECKING, Iterator, Optional + +if TYPE_CHECKING: + from je_auto_control.utils.run_history.history_store import HistoryStore + + +@dataclass(frozen=True) +class HistoryBinding: + """An existing history row and its owning store; never creates a row itself.""" + + store: HistoryStore + run_id: int + + +_CURRENT: ContextVar[Optional[HistoryBinding]] = ContextVar("je_auto_control_history_owner", default=None) + + +def current_history() -> Optional[HistoryBinding]: + """Return the enclosing execution's history owner, if any.""" + return _CURRENT.get() + + +@contextmanager +def bound_history(binding: Optional[HistoryBinding]) -> Iterator[None]: + """Bind an existing owner, or clear ambient ownership; restore on exit.""" + token = _CURRENT.set(binding) + try: + yield + finally: + _CURRENT.reset(token) diff --git a/je_auto_control/utils/run_history/history_store.py b/je_auto_control/utils/run_history/history_store.py index a0b63fb84..f724aa9ad 100644 --- a/je_auto_control/utils/run_history/history_store.py +++ b/je_auto_control/utils/run_history/history_store.py @@ -10,7 +10,7 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Sequence, Union, cast from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger @@ -59,6 +59,12 @@ ); CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at DESC); CREATE INDEX IF NOT EXISTS idx_runs_source ON runs(source_type, source_id); +CREATE TABLE IF NOT EXISTS run_journals ( + run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + journal_path TEXT NOT NULL, + journal_run_id TEXT NOT NULL, + PRIMARY KEY (run_id, journal_path, journal_run_id) +); """ @@ -83,6 +89,15 @@ def duration_seconds(self) -> Optional[float]: return max(0.0, self.finished_at - self.started_at) +@dataclass(frozen=True) +class JournalLink: + """Non-owning provenance from a history row to one run in an external journal.""" + + run_id: int + journal_path: str + journal_run_id: str + + def _default_history_path() -> Path: """Return the per-user cache path for the default store.""" return Path.home() / ".je_auto_control" / "run_history.sqlite" @@ -142,13 +157,18 @@ def _connection(self) -> "sqlite3.Connection": driver = require_sqlite3() if self.path != _IN_MEMORY_DB: os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) - conn = driver.connect( + conn = cast("sqlite3.Connection", driver.connect( self.path, check_same_thread=False, isolation_level=None, - ) - conn.row_factory = driver.Row - conn.executescript(_SCHEMA) + )) + try: + conn.row_factory = driver.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.executescript(_SCHEMA) + self._migrate_schema_locked(conn) + except BaseException: # reason: release a failed initialization, then propagate original control flow + conn.close() + raise self._conn = conn - self._migrate_schema_locked(conn) return conn def _migrate_schema_locked(self, conn: "sqlite3.Connection") -> None: @@ -213,6 +233,37 @@ def attach_artifact(self, run_id: int, artifact_path: str) -> bool: ) return cursor.rowcount > 0 + @sqlite_errors_as(HistoryStoreError) + def attach_journal(self, run_id: int, journal_path: str, journal_run_id: str) -> bool: + """Idempotently link a journal run to an existing row without owning its file.""" + for value in (journal_path, journal_run_id): + if not isinstance(value, str) or not value: + raise HistoryStoreError("journal metadata must contain non-empty strings") + with self._lock: + conn = self._connection() + conn.execute( + "INSERT OR IGNORE INTO run_journals(run_id, journal_path, journal_run_id)" + " SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?)", + (int(run_id), journal_path, journal_run_id, int(run_id)), + ) + row = conn.execute( + "SELECT 1 FROM run_journals j JOIN runs r ON r.id = j.run_id" + " WHERE j.run_id = ? AND j.journal_path = ? AND j.journal_run_id = ?", + (int(run_id), journal_path, journal_run_id), + ).fetchone() + return row is not None + + @sqlite_errors_as(HistoryStoreError) + def journals_for_run(self, run_id: int) -> List[JournalLink]: + """List a history row's journal links in attachment order, without reading files.""" + with self._lock: + rows = self._connection().execute( + "SELECT j.run_id, j.journal_path, j.journal_run_id FROM run_journals j" + " JOIN runs r ON r.id = j.run_id WHERE j.run_id = ? ORDER BY j.rowid", + (int(run_id),), + ).fetchall() + return [JournalLink(int(row["run_id"]), str(row["journal_path"]), str(row["journal_run_id"])) for row in rows] + @sqlite_errors_as(HistoryStoreError) def list_runs(self, limit: int = 100, source_type: Optional[str] = None, @@ -342,7 +393,7 @@ def _row_to_record(row: "sqlite3.Row") -> RunRecord: ) -def _remove_artifact_files(paths) -> None: +def _remove_artifact_files(paths: Iterable[Optional[str]]) -> None: """Best-effort delete of artifact files; ignore missing entries.""" for raw in paths: if not raw: diff --git a/je_auto_control/utils/scheduler/scheduler.py b/je_auto_control/utils/scheduler/scheduler.py index 4f8a66498..646aa244f 100644 --- a/je_auto_control/utils/scheduler/scheduler.py +++ b/je_auto_control/utils/scheduler/scheduler.py @@ -12,6 +12,7 @@ from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.context import HistoryBinding, bound_history from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( @@ -237,7 +238,7 @@ def _fire(self, job: ScheduledJob, now_mono: float, now_wall: float) -> None: status = STATUS_OK error_text: Optional[str] = None try: - with job._authorization.scope(): + with job._authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(job.script_path) run_counting_failures(lambda: self._execute(actions)) # 一個排程工作失敗必須記錄為 STATUS_ERROR 並繼續輪詢,絕不能拖垮 diff --git a/je_auto_control/utils/sqlite_support.py b/je_auto_control/utils/sqlite_support.py index 521164af4..58dae9ea8 100644 --- a/je_auto_control/utils/sqlite_support.py +++ b/je_auto_control/utils/sqlite_support.py @@ -11,6 +11,7 @@ """ import functools from contextlib import contextmanager +from types import ModuleType from typing import Any, Callable, Iterator, Tuple, Type, TypeVar from je_auto_control.utils.exception.exceptions import ( @@ -99,7 +100,7 @@ def sqlite3_available() -> bool: return _sqlite3 is not None -def require_sqlite3(): +def require_sqlite3() -> ModuleType: """Return the ``sqlite3`` module, raising if this build does not have it. Raises ``AutoControlUnsupportedOperationException`` -- the same type the diff --git a/je_auto_control/utils/triggers/email_trigger.py b/je_auto_control/utils/triggers/email_trigger.py index a13c1ee6d..81bfc72f8 100644 --- a/je_auto_control/utils/triggers/email_trigger.py +++ b/je_auto_control/utils/triggers/email_trigger.py @@ -27,6 +27,7 @@ from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.context import HistoryBinding, bound_history from je_auto_control.utils.logging.secret_scope import scrub_secret_text, secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.run_history.artifact_manager import ( @@ -493,7 +494,7 @@ def _execute_with_history(self, trigger: EmailTrigger, status = STATUS_OK error_text: Optional[str] = None try: - with trigger._authorization.scope(): + with trigger._authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(trigger.script_path) run_counting_failures(lambda: self._executor(actions, payload)) # Any failure is recorded as STATUS_ERROR -- not a bogus diff --git a/je_auto_control/utils/triggers/trigger_engine.py b/je_auto_control/utils/triggers/trigger_engine.py index 2ddcdc16c..d44edbf0e 100644 --- a/je_auto_control/utils/triggers/trigger_engine.py +++ b/je_auto_control/utils/triggers/trigger_engine.py @@ -17,6 +17,7 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.context import HistoryBinding, bound_history from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization from je_auto_control.utils.timeouts import clamp_poll_interval @@ -374,7 +375,7 @@ def _fire(self, trigger: _TriggerBase, now: float) -> None: status = STATUS_OK error_text: Optional[str] = None try: - with authorization.scope(): + with authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(trigger.script_path) run_counting_failures(lambda: self._execute(actions)) # 這裡刻意攔截所有例外:一個 trigger 失敗必須記錄成 STATUS_ERROR diff --git a/je_auto_control/utils/triggers/webhook_server.py b/je_auto_control/utils/triggers/webhook_server.py index 8bd560890..a9e245efe 100644 --- a/je_auto_control/utils/triggers/webhook_server.py +++ b/je_auto_control/utils/triggers/webhook_server.py @@ -41,6 +41,7 @@ ) from je_auto_control.utils.json.json_file import read_executable_action_json from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.context import HistoryBinding, bound_history from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.authorization import AuthorizationError from je_auto_control.utils.rbac.deferred import DeferredAuthorization @@ -390,7 +391,7 @@ def fire(self, trigger: WebhookTrigger, # response. Catch the whole family so the fire is recorded as an # error and _dispatch still answers the request. try: - with trigger._authorization.scope(): + with trigger._authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(trigger.script_path) run_counting_failures(lambda: self._executor(actions, payload)) except Exception as error: # noqa: BLE001 # reason: any script failure must be recorded and answered diff --git a/test/unit_test/headless/test_journal_history_links.py b/test/unit_test/headless/test_journal_history_links.py new file mode 100644 index 000000000..c566d0a31 --- /dev/null +++ b/test/unit_test/headless/test_journal_history_links.py @@ -0,0 +1,218 @@ +"""Journal provenance follows explicit history owners without owning journal files.""" +import json +from pathlib import Path + +import pytest + +from je_auto_control.utils.action_journal import ActionJournal, journal_scope +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.run_history.history_store import ( + HistoryStore, HistoryStoreError, SOURCE_MANUAL, STATUS_OK, +) + + +@pytest.fixture +def store(): + history = HistoryStore() + try: + yield history + finally: + history.close() + + +def test_multiple_journals_attach_to_one_history_run_idempotently(store, tmp_path): + run = store.start_run(SOURCE_MANUAL, "manual", "script.json") + first, second = str(tmp_path / "first.jsonl"), str(tmp_path / "second.jsonl") + assert store.attach_journal(run, first, "one") + assert store.attach_journal(run, first, "one") + assert store.attach_journal(run, second, "two") + links = store.journals_for_run(run) + assert [(item.run_id, item.journal_path, item.journal_run_id) for item in links] == [ + (run, first, "one"), (run, second, "two"), + ] + assert not Path(first).exists() and not Path(second).exists() + assert store.journals_for_run(run + 1) == [] + assert not store.attach_journal(run + 1, first, "unknown") + + +@pytest.mark.parametrize("field", ["journal_path", "journal_run_id"]) +def test_empty_journal_metadata_is_refused(store, field): + run = store.start_run(SOURCE_MANUAL, "manual", "script.json") + arguments = {"journal_path": "file.jsonl", "journal_run_id": "run"} + arguments[field] = "" + with pytest.raises(HistoryStoreError): + store.attach_journal(run, **arguments) + assert store.journals_for_run(run) == [] + + +@pytest.mark.parametrize("operation", ["clear", "prune"]) +def test_history_cleanup_removes_links_but_never_deletes_journal_files(store, tmp_path, operation): + paths = [tmp_path / "first.jsonl", tmp_path / "second.jsonl"] + runs = [] + for index, path in enumerate(paths): + path.write_text("user-owned journal bytes", encoding="utf-8") + run = store.start_run(SOURCE_MANUAL, str(index), "script.json", started_at=index) + store.attach_journal(run, str(path), str(index)) + store.finish_run(run, STATUS_OK) + runs.append(run) + if operation == "clear": + assert store.clear() == 2 + assert store.journals_for_run(runs[1]) == [] + else: + assert store.prune(1) == 1 + assert len(store.journals_for_run(runs[1])) == 1 + assert store.journals_for_run(runs[0]) == [] + assert all(path.read_text(encoding="utf-8") == "user-owned journal bytes" for path in paths) + + +def test_old_history_schema_migrates_without_changing_existing_rows(tmp_path): + sqlite3 = pytest.importorskip("sqlite3") + path = tmp_path / "legacy.sqlite" + with sqlite3.connect(path) as connection: + connection.executescript(""" + CREATE TABLE runs(id INTEGER PRIMARY KEY AUTOINCREMENT, source_type TEXT, + source_id TEXT, script_path TEXT, started_at REAL, finished_at REAL, + status TEXT, error_text TEXT); + INSERT INTO runs VALUES(1,'manual','legacy','old.json',1,2,'ok',NULL); + """) + history = HistoryStore(path) + try: + assert history.get_run(1).source_id == "legacy" + assert history.attach_journal(1, "old.jsonl", "legacy-run") + assert history.journals_for_run(1)[0].journal_run_id == "legacy-run" + finally: + history.close() + reopened = HistoryStore(path) + try: + assert reopened.get_run(1).source_id == "legacy" + assert reopened.journals_for_run(1)[0].journal_path == "old.jsonl" + finally: + reopened.close() + + +def test_failed_history_initialization_releases_the_connection(store, monkeypatch): + from types import SimpleNamespace + from je_auto_control.utils.run_history import history_store + sqlite3 = pytest.importorskip("sqlite3") + class FailingConnection(sqlite3.Connection): + closed = False + def executescript(self, _script): + raise sqlite3.OperationalError("controlled schema failure") + def close(self): + self.closed = True + super().close() + connection = sqlite3.connect(":memory:", factory=FailingConnection) + driver = SimpleNamespace(Row=sqlite3.Row, connect=lambda *_args, **_kwargs: connection) + monkeypatch.setattr(history_store, "require_sqlite3", lambda: driver) + try: + with pytest.raises(HistoryStoreError, match="controlled schema failure"): + store.attach_journal(1, "unused.jsonl", "unused") + assert connection.closed and store._conn is None + finally: + connection.close() + + +def test_journal_scope_attaches_before_device_effects_and_restores_the_owner(store, tmp_path, monkeypatch): + from je_auto_control.utils.run_history.context import HistoryBinding, bound_history, current_history + run = store.start_run(SOURCE_MANUAL, "manual", "script.json") + owner = HistoryBinding(store, run) + path = tmp_path / "run.jsonl" + reached = [] + def probe(): + links = store.journals_for_run(run) + reached.append((links[0].journal_path, links[0].journal_run_id, current_history())) + monkeypatch.setitem(executor.event_dict, "AC_history_probe", probe) + with bound_history(owner): + with journal_scope(ActionJournal(path), run_id="journal"): + executor.execute_action([["AC_history_probe"]]) + assert current_history() is owner + assert current_history() is None + assert reached == [(str(path.resolve()), "journal", owner)] + + +def test_unknown_history_owner_prevents_the_action_before_any_effect(store, tmp_path, monkeypatch): + from je_auto_control.utils.action_journal import ActionJournalError + from je_auto_control.utils.run_history.context import HistoryBinding, bound_history + reached = [] + monkeypatch.setitem(executor.event_dict, "AC_history_probe", lambda: reached.append(True)) + with bound_history(HistoryBinding(store, 999)), pytest.raises(ActionJournalError, match="history"): + with journal_scope(ActionJournal(tmp_path / "no-effect.jsonl")): + executor.execute_action([["AC_history_probe"]]) + assert reached == [] + + +def test_history_bindings_restore_on_nested_failure_and_explicitly_clear_ambient_owners(store): + from je_auto_control.utils.run_history.context import HistoryBinding, bound_history, current_history + first = HistoryBinding(store, 1) + second = HistoryBinding(store, 2) + with bound_history(first): + with pytest.raises(RuntimeError): + with bound_history(second): + assert current_history() is second + raise RuntimeError("controlled") + assert current_history() is first + with bound_history(None): + assert current_history() is None + assert current_history() is first + assert current_history() is None + + +@pytest.mark.parametrize("kind", ["parallel", "dag"]) +def test_workers_keep_the_parent_history_when_they_start_their_own_journal(store, tmp_path, monkeypatch, kind): + from je_auto_control.utils.run_history.context import HistoryBinding, bound_history + run = store.start_run(SOURCE_MANUAL, "manual", "script.json") + def capture(index): + with journal_scope(ActionJournal(tmp_path / f"{index}.jsonl"), run_id=f"child-{index}"): + executor.execute_action([["AC_history_inner"]]) + monkeypatch.setitem(executor.event_dict, "AC_history_capture", capture) + monkeypatch.setitem(executor.event_dict, "AC_history_inner", lambda: None) + bodies = [[["AC_history_capture", {"index": index}]] for index in range(2)] + script = ([["AC_parallel", {"branches": bodies}]] if kind == "parallel" else + [["AC_run_dag", {"definition": {"nodes": [ + {"id": str(index), "actions": body} for index, body in enumerate(bodies)]}}]]) + with bound_history(HistoryBinding(store, run)): + executor.execute_action(script, raise_on_error=True) + assert {item.journal_run_id for item in store.journals_for_run(run)} == {"child-0", "child-1"} + + +@pytest.mark.parametrize("kind", ["scheduler", "trigger", "hotkey", "webhook", "email"]) +def test_background_run_owners_link_explicit_journals_to_their_existing_row(kind, store, tmp_path, monkeypatch): + from je_auto_control.utils.hotkey import hotkey_daemon + from je_auto_control.utils.scheduler import scheduler + from je_auto_control.utils.triggers import email_trigger, trigger_engine, webhook_server + for module in (hotkey_daemon, scheduler, email_trigger, trigger_engine, webhook_server): + monkeypatch.setattr(module, "default_history_store", store) + monkeypatch.setattr(module, "capture_error_snapshot", lambda *_args: None) + path = tmp_path / "events.jsonl" + def capture(): + with journal_scope(ActionJournal(path), run_id="background"): + executor.execute_action([["AC_history_inner"]]) + monkeypatch.setitem(executor.event_dict, "AC_history_capture", capture) + monkeypatch.setitem(executor.event_dict, "AC_history_inner", lambda: None) + script = tmp_path / "actions.json" + script.write_text(json.dumps([["AC_history_capture"]]), encoding="utf-8") + if kind == "scheduler": + owner = scheduler.Scheduler() + job = owner.add_job(str(script), 60) + owner._fire(job, 0, 1_800_000_000) + elif kind == "trigger": + owner = trigger_engine.TriggerEngine() + trigger = trigger_engine.FilePathTrigger("probe", str(script)) + owner.add(trigger) + owner._fire(trigger, 0) + elif kind == "hotkey": + owner = hotkey_daemon.HotkeyDaemon() + owner.bind("ctrl+alt+a", str(script), binding_id="probe") + owner._fire_binding("probe") + elif kind == "webhook": + owner = webhook_server.WebhookTriggerServer() + trigger = owner.add(path="/probe", script_path=str(script)) + owner.fire(trigger, {}) + else: + owner = email_trigger.EmailTriggerWatcher() + trigger = owner.add("imap.invalid", "user", "unused", str(script)) + owner._execute_with_history(trigger, {}) + rows = store.list_runs() + assert len(rows) == 1 and rows[0].status == STATUS_OK + links = store.journals_for_run(rows[0].id) + assert [(item.journal_path, item.journal_run_id) for item in links] == [(str(path.resolve()), "background")] From fec7ba3efe64f347e917da9cb1f60cec09a39cfd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 18 Oct 2026 23:58:42 +0800 Subject: [PATCH 017/294] Preserve interrupted runs across history and recovery boundaries --- CHANGELOG.md | 7 + Progress.md | 2 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 7 + architecture_explore.md | 54 ++--- .../Eng/doc/new_features/new_features_doc.rst | 23 ++ .../Zh/doc/new_features/new_features_doc.rst | 18 ++ docs/updates/2026-10.md | 9 + docs/updates/README.md | 3 +- .../utils/action_journal/capture.py | 6 +- .../callback/callback_function_executor.py | 3 + je_auto_control/utils/dag/runner.py | 14 +- .../utils/executor/action_executor.py | 2 + .../utils/executor/flow_control.py | 26 ++- je_auto_control/utils/hotkey/hotkey_daemon.py | 26 +-- je_auto_control/utils/mcp_server/context.py | 5 +- je_auto_control/utils/observer/observer.py | 4 +- .../utils/run_history/run_outcome.py | 59 ++++- je_auto_control/utils/scheduler/scheduler.py | 34 +-- .../utils/triggers/email_trigger.py | 20 +- .../utils/triggers/trigger_engine.py | 33 +-- .../utils/triggers/webhook_server.py | 30 ++- .../utils/watchdog/popup_watchdog.py | 3 +- .../headless/test_history_interruption.py | 204 ++++++++++++++++++ 26 files changed, 460 insertions(+), 138 deletions(-) create mode 100644 test/unit_test/headless/test_history_interruption.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f8a942b..b4b2b08d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Interrupted background attempts now settle history as `error` and skip error + screenshots. Hard exits retain their original control flow even if history + completion fails. Cancellation bypasses non-strict action failure recording, + retry/recovery, callback and image-condition guards; local parallel/DAG workers + preserve the original signal. Explicit script `finally` cleanup still runs. + Ordinary failure behavior and the MCP cancellation exception family are unchanged. + - Explicit action journals now link to an enclosing existing run-history row, including local parallel/DAG captures. Links are idempotent and non-owning; clearing/pruning history keeps external journal files. Plain captures create diff --git a/Progress.md b/Progress.md index 66a7526a0..2d123167a 100644 --- a/Progress.md +++ b/Progress.md @@ -222,7 +222,7 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 -- **背景執行的硬中止狀態**(`TODO`):`utils/{scheduler,triggers,hotkey}/` 的 run owner 只在 `Exception` 設錯誤狀態,`KeyboardInterrupt`/`SystemExit` 等硬中止仍會由 finally 寫成 history `ok`;需改為失敗/中止結果並保留原始控制流程,且中止後不再擷取錯誤截圖。 +- **DAG 工作執行緒的診斷遮罩**(`TODO`):`utils/dag/runner.py::_run_one` 的一般失敗日誌在 runner 的遮罩退出後輸出,仍會留下解析後的秘密;需涵蓋 node 的完整執行與錯誤處理,並驗證直接呼叫與自訂 runner。 - **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index 433d5020b..2e513396a 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. Facade, command, CLI/MCP/GUI and manual-run history controls remain pending integration. -Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. +Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. Interrupted history runs are recorded as `error`, while journal steps remain `cancelled`; no error screenshot is taken. Cancellation propagates through non-strict execution, recovery/retry, callbacks and local workers. Hard exits retain their original signal even if history completion fails. ## Command-line interface diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index a875da9c4..45d5cc25a 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -247,7 +247,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。门面、动作命令、CLI/MCP/GUI 与手动执行的 history 控制仍待接入。 -`${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。 +`${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。中止的 history 记为 `error`,journal 步骤保持 `cancelled`,不再获取错误截图。取消会穿过非严格执行、恢复/重试、回调与本机 worker;history 结算失败也不会取代原始硬中止信号。 ## 命令行界面 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 6d7b075ad..a4506f8ce 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -246,7 +246,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。門面、動作指令、CLI/MCP/GUI 與手動執行的 history 控制仍待接入。 -`${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。 +`${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。中止的 history 記為 `error`,journal 步驟維持 `cancelled`,不再擷取錯誤截圖。取消會穿過非嚴格執行、復原/重試、回呼與本機 worker;history 結算失敗也不會取代原始硬中止訊號。 ## 命令列介面 diff --git a/architecture.md b/architecture.md index 9576aba18..7cb671a1c 100644 --- a/architecture.md +++ b/architecture.md @@ -130,6 +130,13 @@ Scheduler/trigger/hotkey/webhook/email owners bind their own row; local parallel and DAG workers carry it explicitly. `journal_scope` attaches an idempotent non-owning path/run-ID link before dispatch. `run_journals` cascades metadata when rows are pruned/cleared; external journal files are never deleted by history. +`run_history/run_outcome.py` shares interruption classification and finalization. +Cancellation bypasses ordinary executor recovery/retry and callback guards; +parallel/DAG workers preserve the original signal. History settles interrupted +attempts as `error`, without a snapshot; a failed completion preserves a pending +hard signal and leaves the history row unfinished. Explicit script `finally` +branches retain their cleanup contract. Already-started parallel branches are +joined before propagation; this is not forcible interruption of native I/O. Facade/command/CLI/MCP/GUI and manual-run history controls remain pending. **C. Record → edit → generate code** diff --git a/architecture_explore.md b/architecture_explore.md index f62876fa9..2b1379417 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,087 | -| 程式碼總行數 | 162,994 | +| 程式碼總行數 | 163,047 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,852 行。 +> 25 個套件、約 15,878 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -280,11 +280,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/action_signing/` | 716 | action 檔 HMAC-SHA256 簽章與 Fernet 加密,`execute_files` 會強制驗簽 | | `utils/checkpoint/` | 129 | 流程檢查點與續跑,讓長 action list 具持久性 | | `utils/codegen/` | 294 | 由 action list 產生可執行的 pytest / python / robot 測試碼 | -| `utils/dag/` | 566 | 跨主機 DAG 編排器(圖模型 + runner) | +| `utils/dag/` | 578 | 跨主機 DAG 編排器(圖模型 + runner) | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | -| `utils/action_journal/` | 589 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | -| `utils/executor/` | 9,684 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/action_journal/` | 587 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | +| `utils/executor/` | 9,700 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -304,11 +304,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.2 框架基礎設施 -> 14 個套件、約 3,444 行。 +> 14 個套件、約 3,447 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `utils/callback/` | 211 | Observer 模式:`callback_executor` 以字串名觸發功能;trigger 與 callback 共用秘密遮罩至錯誤處理結束 | +| `utils/callback/` | 214 | Observer 模式:`callback_executor` 以字串名觸發功能;trigger 與 callback 共用秘密遮罩至錯誤處理結束 | | `utils/config_bundle/` | 424 | 使用者設定的單檔匯出/匯入 | | `utils/critical_exit/` | 132 | 監看緊急停止鍵的守護執行緒,用於中止失控腳本 | | `utils/diagnostics/` | 330 | 跨子系統的「一切正常嗎」健檢,附 `python -m` 進入點 | @@ -325,20 +325,20 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.3 排程、觸發與背景監看 -> 11 個套件、約 4,388 行。 +> 11 個套件、約 4,358 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | | `utils/hotkey/` | 861 | 全域熱鍵守護行程,把 OS 層熱鍵綁到 action 檔(Win/macOS/X11 三後端) | | `utils/idle_keepawake/` | 245 | 偵測使用者閒置時間並在無人值守執行期間阻止系統睡眠 | | `utils/lock_session/` | 166 | 鎖定工作站、等待解鎖並分類鎖定狀態轉換 | -| `utils/observer/` | 248 | 反應式畫面觀察者;每條規則涵蓋 predicate/handler 的秘密遮罩與隔離 | +| `utils/observer/` | 250 | 反應式畫面觀察者;每條規則涵蓋 predicate/handler 的秘密遮罩與隔離 | | `utils/recurrence/` | 470 | RFC 5545 重複規則解析與發生時間展開 | -| `utils/scheduler/` | 515 | 間隔式與 cron 式的 action JSON 排程器 | +| `utils/scheduler/` | 503 | 間隔式與 cron 式的 action JSON 排程器 | | `utils/session_guard/` | 62 | 驅動輸入前先偵測工作階段是否已鎖定/非互動 | -| `utils/triggers/` | 1,422 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | +| `utils/triggers/` | 1,401 | 事件驅動觸發引擎:影像/視窗/像素/檔案/webhook/IMAP 郵件 | | `utils/voice/` | 99 | 語音指令路由:把辨識到的語句對應到 `AC_*` action list | -| `utils/watchdog/` | 210 | 背景彈窗/中斷看門狗;每條規則涵蓋 matcher/action 與錯誤日誌的秘密遮罩 | +| `utils/watchdog/` | 211 | 背景彈窗/中斷看門狗;每條規則涵蓋 matcher/action 與錯誤日誌的秘密遮罩 | | `utils/watcher/` | 90 | 無頭輪詢原語:滑鼠位置、像素顏色、log tail | ### 5.4.4 輸入模擬與動作品質 @@ -495,7 +495,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.9 AI / Agent / LLM -> 13 個套件、約 23,690 行。 +> 13 個套件、約 23,689 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -508,7 +508,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/cua_action/` | 204 | 標準化 computer-use 動作結構(Anthropic/OpenAI → `AC_*`) | | `utils/llm/` | 372 | 自然語言 → action list 規劃器 + Anthropic/null 後端;規劃執行隔離頂層變數並沿用巢狀範圍 | | `utils/mcp_registry/` | 97 | MCP registry `server.json` 資訊清單產生(可被發現) | -| `utils/mcp_server/` | 19,168 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | +| `utils/mcp_server/` | 19,167 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | | `utils/tool_use_schema/` | 195 | 把 `AC_*` 指令匯出成 Claude/OpenAI 的 tool-use schema | | `utils/trajectory_eval/` | 132 | agent 軌跡評估:依評分規準為一次執行打分 | | `utils/vision/` | 538 | VLM 元素定位器(依描述找元素)+ Anthropic/OpenAI/null 後端 | @@ -559,7 +559,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.12 報表、可觀測性與測試治理 -> 34 個套件、約 7,815 行。 +> 34 個套件、約 7,870 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -584,7 +584,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/profiler/` | 451 | 逐動作效能剖析器 + 資源剖析器 | | `utils/quarantine/` | 204 | 易碎測試隔離區,讓套件執行器跳過已知不穩定案例 | | `utils/run_diff/` | 143 | 兩次執行軌跡的差異(LCS 對齊:新增/移除/狀態翻轉/退化) | -| `utils/run_history/` | 548 | 執行歷史、產出物與非擁有式 journal 連結;既有 run 的 context 傳入本機 workers,清理 history 不刪日誌檔 | +| `utils/run_history/` | 603 | 執行歷史、產出物與非擁有式 journal 連結;既有 run 的 context 傳入本機 workers,清理 history 不刪日誌檔 | | `utils/sarif/` | 187 | 以 SARIF 2.1.0 匯出發現項,供 GitHub/Azure code scanning | | `utils/slo/` | 115 | SLO 評估:SLI、錯誤預算與多視窗燃燒率告警 | | `utils/smoothing/` | 67 | 數列移動平均平滑 | @@ -697,19 +697,19 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,684 行)— 執行核心 +#### `utils/executor/`(9,700 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,445 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | -| `flow_control.py` | 664 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | +| `action_executor.py` | 8,447 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `flow_control.py` | 678 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | | `action_schema.py` | 159 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | | `action_redaction.py` | 86 | 記錄與紀錄鍵用的遮蔽:`AC_secret_*` 的參數(金庫通行碼、機密值)在寫進 log、當成結果紀錄的鍵之前換成 `***`,巢狀在區塊指令裡的也一樣。 | | `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 | | `flags.py` | 19 | 旗標讀取 `as_bool`:依拼法(`"true"`/`"yes"`/`"on"`/`"1"`)而非 Python 真值判斷,所以 JSON、CLI、MCP 傳來的 `"false"` 是關;所有 adapter 與流程指令共用。 | -#### `utils/mcp_server/`(19,168 行,680 個工具)— 最大子系統 +#### `utils/mcp_server/`(19,167 行,680 個工具)— 最大子系統 | 檔案 | 行數 | 職責 | | --- | ---: | --- | @@ -743,7 +743,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `tools/plugin_tools.py` | 89 | 把外掛載入的 `AC_*` callable 包成 `MCPTool`。 | | `log_bridge.py` | 118 | 把 Python logging 記錄橋接成 MCP `notifications/message`;2026-07-28 的請求只收到自己設了 `logLevel` 時產生的記錄。 | | `audit.py` | 114 | MCP 工具呼叫稽核記錄。 | -| `context.py` | 71 | 傳給 opt-in 工具處理器的每次呼叫上下文。 | +| `context.py` | 70 | 傳給 opt-in 工具處理器的每次呼叫上下文。 | | `rate_limit.py` | 48 | 工具呼叫的 token bucket 限流。 | | `__main__.py` | 92 | `je_auto_control_mcp` console script 進入點。 | @@ -856,7 +856,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `action_lint/` | `linter.py`、`schema.py`、`__main__.py`(CI 使用) | | `time_travel/` | `controller.py`、`player.py` | | `dag/` | `graph.py`、`runner.py` | -| `run_history/` | `history_store.py`、`context.py`(非擁有式 journal 連結與 worker 擁有者)、`artifact_manager.py`、`run_outcome.py`(動作失敗判為錯誤) | +| `run_history/` | `history_store.py`、`context.py`(非擁有式 journal 連結與 worker 擁有者)、`artifact_manager.py`、`run_outcome.py`(一般失敗計數、中止分類與保留硬中止的歷史結算) | | `self_healing/` | `locator.py`、`heal_log.py` | | `ab_locator/` | `runner.py`、`store.py` | | `cost_telemetry/` | `pricing.py`、`store.py` | @@ -1083,9 +1083,9 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | | `gui/` | 100 | 28,788 | -| `utils/mcp_server/` | 37 | 19,168 | +| `utils/mcp_server/` | 37 | 19,167 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,684 | +| `utils/executor/` | 8 | 9,700 | | `utils/usb/` | 17 | 4,816 | | `je_auto_control/`(頂層 3 檔) | 3 | 2,470 | | `utils/accessibility/` | 14 | 3,143 | @@ -1095,13 +1095,13 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `utils/agent/` | 9 | 2,142 | | `linux_with_x11/` | 19 | 1,281 | | `linux_wayland/` | 17 | 2,921 | -| `utils/triggers/` | 4 | 1,422 | +| `utils/triggers/` | 4 | 1,401 | | `utils/ocr/` | 10 | 1,381 | | `utils/usbip/` | 5 | 1,008 | | `utils/assertion/` | 3 | 887 | | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 697 | 59,614 | -| **總計** | **1,081** | **162,929** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 697 | 59,673 | +| **總計** | **1,081** | **162,982** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 6538fba56..742126ccb 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1567,3 +1567,26 @@ Clearing/pruning history removes links and preserves journal files. Existing history schemas migrate on opening; original rows and screenshot links survive. An unknown bound history row refuses capture before device effects. GUI and manual-run convenience wiring remain pending integration. + + +Interrupted run outcomes +------------------------ + +A raised ``KeyboardInterrupt``, ``SystemExit``, ``GeneratorExit``, asyncio/future +cancellation or MCP ``OperationCancelledError`` must not turn an interrupted +attempt into a successful history row. History records ``error``; captured +journal steps record ``cancelled``. Interrupted attempts do not take an error +screenshot. Ordinary failures retain their existing snapshot/recovery behavior. + +Cancellation bypasses non-strict executor failure recording, ``AC_try`` recovery, +``AC_retry`` retries, image-condition absence handling and callback failure +guards. Explicit script ``finally`` cleanup still runs. Local parallel and DAG +workers return the original signal to the owner; already-started parallel +branches are joined first. These are propagation rules once a signal is raised, +not forcible interruption of a blocking native operation. + +Hard control signals propagate unchanged. If history completion fails while a +hard signal is already pending, diagnostic storage cannot replace that signal; +the history row remains unfinished rather than being marked successful. +MCP cancellation keeps its existing ``RuntimeError`` control-signal contract, +separate from ordinary framework failures, and its JSON-RPC cancellation reply. diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index b7b8d7965..fd05697bd 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1453,3 +1453,21 @@ run ID;本機 parallel 與預設 DAG worker 另開擷取時也保留擁有者 舊 history schema 會在開啟時遷移,保留原有列與截圖連結。 綁定不存在的 history 列時,會在裝置動作發生前拒絕擷取。 GUI 與手動執行的便利接線仍待整合。 + + +中止執行的結果 +------------------------ + +拋出 ``KeyboardInterrupt``、``SystemExit``、``GeneratorExit``、asyncio/future 取消 +或 MCP ``OperationCancelledError`` 時,不可將該次執行寫成成功。 +History 記為 ``error``,journal 步驟記為 ``cancelled``,且不擷取錯誤截圖。 +一般失敗仍保留既有截圖與復原行為。 + +取消會穿過非嚴格 executor 的錯誤紀錄、``AC_try`` 的 catch、``AC_retry`` 的重試、 +影像條件的不存在判定,以及 callback 的一般錯誤防護;腳本明確指定的 ``finally`` +清理仍會執行。本機 parallel/DAG worker 會把原始訊號送回擁有者,parallel 會先等候 +已啟動的分支結束。這些規則處理已拋出的中止訊號,不是強制中斷阻塞中的原生操作。 + +硬中止訊號保留原樣傳遞。若 history 結算在硬中止傳遞期間失敗,診斷儲存不可取代 +原始訊號;該列維持未結算,不會被標為成功。MCP 取消保留既有 ``RuntimeError`` +控制訊號契約,與一般框架失敗分開,也保留原有 JSON-RPC 取消回覆。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 8f76f1fa0..45fde9238 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -444,3 +444,12 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: the full suite passes 11,689 tests with 69 skipped and 87.75% coverage. 17 new cases cover multiple/idempotent links, missing owners and invalid metadata, legacy migration/reopen, clear/prune file preservation, nested/restored/cleared bindings, both local worker paths, all five background owners and initialization failure. The related selection passes 229 tests with 92.95% combined scoped history/context coverage; context coverage is complete. Another 14 optional-SQLite/store cases and 44 documentation, budget, exception and light-import gates pass. Four touched modules pass strict mypy and the full three-platform typing gate passes; ruff and bandit pass. New context code scores 10.00/10 in pylint; the modified legacy modules retain only the existing RunRecord attribute-count and path-docstring findings at the repository's duplication threshold. Strict English/Chinese Sphinx and wheel/source builds pass; all 12 changed production sources match the wheel. - **Files**: `utils/run_history/context.py`, history store/package exports, journal capture, parallel/DAG bindings, five background owners, SQLite typing, tests, three READMEs, Sphinx journal reference, changelog and measured architecture maps. - **Open items**: B1 facade/command/CLI/MCP/GUI, manual-run history controls, hard-cancellation history status and journal browsing remain in `Progress.md`. A controlled scheduler probe confirmed a cancelled journal linked to an incorrectly successful legacy history row; this remains a separate follow-up. Native and paid-API acceptance remain deferred. + +## U-20261018-19 · 2026-10-18 · Preserve interrupted execution through history, workers and recovery guards · #done #executor #cancellation + +- **What**: typed `RunOutcome` shares interruption classification and history finalization across scheduler, trigger, hotkey, webhook and email owners. Interrupted attempts settle as `error` and skip screenshots; a pending hard signal survives a history-write failure, leaving the row unfinished rather than falsely successful. Ordinary failure containment and snapshots remain unchanged. +- **Propagation**: cancellation bypasses non-strict executor recording, try/retry recovery, image-absence handling and callback/observer/watchdog failure guards. Parallel workers return hard and cooperative signals to the caller after joining; DAG nodes settle failed/timing metadata before propagating. The journal recognizes both asyncio and futures cancellation. Explicit script `finally` cleanup still runs. This preserves raised signals rather than forcibly interrupting native I/O or already-started sibling branches. +- **Compatibility**: MCP `OperationCancelledError` intentionally stays outside the ordinary framework exception family and retains its RuntimeError/API cancellation contract. Its notifier/progress annotations and the new shared outcome helpers pass strict mypy. +- **Validation**: the full suite passes 11,760 tests with 69 skipped and 87.77% coverage. 71 new cases cover six interruption classes across five owners, history outages while hard signals propagate, ordinary-failure behavior, both local worker types, retry/recovery/image conditions and callback boundaries. The related journal/history/deferred/callback/flow selection passes 313 tests; 44 documentation, file-budget, complexity, exception and light-import checks pass. Three touched modules pass strict mypy; whole-package three-platform typing, ruff and bandit pass. Outcome/context code scores 10.00/10 in pylint. Strict English/Chinese Sphinx and wheel/source builds pass, and all 14 changed production modules match the wheel. Jeffrey_RPA's facade/control suites pass 751 tests against this tree (five existing Discord deprecations). +- **Files**: run outcome/finalization, five history owners, executor/flow/DAG propagation, callback/observer/watchdog guards, journal capture, MCP context annotations, regression tests, three READMEs, Sphinx journal guide, changelog and measured architecture maps. +- **Open items**: B1 operation surfaces/manual history wiring, the separately reproduced DAG node diagnostic-mask lifetime gap and F3 shared-worker cancellation remain in `Progress.md`; native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index e6a5410d1..1384e7540 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261018-19 | 2026-10-18 | Preserve interrupted execution through history, workers and recovery guards | #done #executor #cancellation | [2026-10](2026-10.md) | | U-20261018-18 | 2026-10-18 | Link explicit journals to existing run-history owners | #done #journal #history | [2026-10](2026-10.md) | | U-20261018-17 | 2026-10-18 | Release SQLite connections after failed history initialization | #incident #history #storage | [2026-10](2026-10.md) | | U-20261018-16 | 2026-10-18 | Retain callback-owner secret masks through final error logging | #done #security #callbacks | [2026-10](2026-10.md) | @@ -381,7 +382,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 45 | +| [2026-10.md](2026-10.md) | 2026-10 | 46 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/action_journal/capture.py b/je_auto_control/utils/action_journal/capture.py index 6ad546e70..f656a7ddf 100644 --- a/je_auto_control/utils/action_journal/capture.py +++ b/je_auto_control/utils/action_journal/capture.py @@ -2,7 +2,6 @@ from __future__ import annotations import time -from asyncio import CancelledError from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, replace @@ -17,6 +16,7 @@ from je_auto_control.utils.path_guard.path_guard import validate_path from je_auto_control.utils.script_vars.execution import execution_scope from je_auto_control.utils.run_history.context import current_history +from je_auto_control.utils.run_history.run_outcome import is_run_interruption @dataclass(frozen=True) @@ -104,11 +104,9 @@ def _failure_count() -> int: def _exception_status(error: BaseException) -> EventStatus: # pylint: disable-next=import-outside-toplevel # reason: flow_control imports this capture adapter from je_auto_control.utils.executor.flow_control import LoopBreak, LoopContinue - # pylint: disable-next=import-outside-toplevel # reason: MCP imports the executor during facade setup - from je_auto_control.utils.mcp_server.context import OperationCancelledError if isinstance(error, (LoopBreak, LoopContinue)): return "ok" # the enclosing loop consumes the signal - if isinstance(error, (KeyboardInterrupt, SystemExit, GeneratorExit, CancelledError, OperationCancelledError)): + if is_run_interruption(error): return "cancelled" return "error" diff --git a/je_auto_control/utils/callback/callback_function_executor.py b/je_auto_control/utils/callback/callback_function_executor.py index f627ce748..458a3549a 100644 --- a/je_auto_control/utils/callback/callback_function_executor.py +++ b/je_auto_control/utils/callback/callback_function_executor.py @@ -10,6 +10,7 @@ ) from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.logging.secret_scope import secret_log_scope +from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted # executor from je_auto_control.utils.executor.action_executor import execute_action, execute_files from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS @@ -180,6 +181,7 @@ def callback_function( except AutoControlAssertionException: raise # an assertion failure is the script's verdict, never a None except Exception as error: # noqa: BLE001 # reason: documented to return None + raise_if_run_interrupted(error) autocontrol_logger.error("callback_function trigger failed: %r", error) return None @@ -189,6 +191,7 @@ def callback_function( self._invoke_callback( callback_function, callback_function_param, callback_param_method) except Exception as error: # noqa: BLE001 # reason: logged; the trigger result stands + raise_if_run_interrupted(error) autocontrol_logger.error("callback_function callback failed: %r", error) return execute_return_value diff --git a/je_auto_control/utils/dag/runner.py b/je_auto_control/utils/dag/runner.py index 393a4fbff..593c20161 100644 --- a/je_auto_control/utils/dag/runner.py +++ b/je_auto_control/utils/dag/runner.py @@ -18,6 +18,7 @@ DagDefinition, DagDefinitionError, DagNode, parse_definition, ) from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.run_history.run_outcome import is_run_interruption from je_auto_control.utils.logging.logging_instance import autocontrol_logger @@ -221,6 +222,8 @@ def _record_future(nid: str, finished: Future, finished.result() except (AutoControlException, RuntimeError, OSError, ValueError, LookupError, TypeError, AttributeError, ArithmeticError) as error: + if is_run_interruption(error): + raise # Defensive: _run_one swallows tool errors into NodeResult. # This branch only catches pool / runner-side faults. It includes # AutoControlException so a framework failure that slips past @@ -238,12 +241,15 @@ def _run_one(node: DagNode, result: NodeResult, result.error = "stopped" # A runner is user code and may raise any Exception subclass; one that # escaped left the node "running" and run_dag returned no result. - except Exception as error: # noqa: BLE001 # reason: any node failure becomes a failed NodeResult and a skip cascade, never a crash of the whole run + except BaseException as error: # reason: settle node state; cancellation/control signals re-raise # AutoControlException covers validate_actions / locate / assert # failures raised by the executor: a node failure becomes a failed # NodeResult (and a downstream skip cascade), not a raw crash that # leaves the node stuck "running" and returns no DagRunResult. result.status = STATUS_FAILED + if not isinstance(error, Exception) or is_run_interruption(error): + result.error = f"{type(error).__name__}: run interrupted" + raise result.error = f"{type(error).__name__}: {error}" autocontrol_logger.warning( f"DAG node {node.id!r} failed: {result.error}", @@ -251,6 +257,12 @@ def _run_one(node: DagNode, result: NodeResult, else: result.status = STATUS_SUCCEEDED result.result = outcome + finally: + _finish_node_timing(result) + + +def _finish_node_timing(result: NodeResult) -> None: + """Settle timing even when a node propagates a cancellation or hard exit.""" finished_at = time.monotonic() result.finished_at = finished_at if result.started_at is not None: diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index eda18a035..be934616c 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -35,6 +35,7 @@ from je_auto_control.utils.executor.action_redaction import describe_action, redact_actions from je_auto_control.utils.action_journal.capture import action_step from je_auto_control.utils.logging.secret_scope import secret_log_scope +from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS from je_auto_control.utils.llm.planner import ( plan_actions as llm_plan_actions, @@ -8325,6 +8326,7 @@ def _run_one_action(self, action: list, record: Dict[str, Any], # under raise_on_error=False — otherwise the assertion would be # silently neutralised. ``AC_try``/``AC_retry`` run their body with # raise_on_error=True and still catch it via their own tuples. + raise_if_run_interrupted(error) if raise_on_error or isinstance( error, (AutoControlAssertionException, MacroDepthExceeded)): raise diff --git a/je_auto_control/utils/executor/flow_control.py b/je_auto_control/utils/executor/flow_control.py index 0ab46230d..9e579b0cf 100644 --- a/je_auto_control/utils/executor/flow_control.py +++ b/je_auto_control/utils/executor/flow_control.py @@ -26,6 +26,7 @@ from je_auto_control.utils.action_journal.capture import bound_journal, current_journal from je_auto_control.utils.logging.secret_scope import bound_secret_scope, current_secret_scope from je_auto_control.utils.run_history.context import bound_history, current_history +from je_auto_control.utils.run_history.run_outcome import is_run_interruption, raise_if_run_interrupted from je_auto_control.wrapper.auto_control_image import locate_image_center from je_auto_control.wrapper.auto_control_screen import get_pixel from je_auto_control.utils.timeouts import deadline_after @@ -52,7 +53,8 @@ def _image_present(image: str, threshold: float) -> bool: try: locate_image_center(image, threshold) return True - except (ImageNotFoundException, OSError, RuntimeError, ValueError, TypeError): + except (ImageNotFoundException, OSError, RuntimeError, ValueError, TypeError) as error: + raise_if_run_interrupted(error) return False @@ -217,6 +219,7 @@ def exec_retry(executor: Any, args: Mapping[str, Any]) -> Any: except MacroDepthExceeded: raise except _TRY_CATCHABLE as error: + raise_if_run_interrupted(error) last_error = error autocontrol_logger.info( "AC_retry attempt %d/%d failed: %s", @@ -254,6 +257,7 @@ def exec_try(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]: except (LoopBreak, LoopContinue, MacroDepthExceeded): raise except _TRY_CATCHABLE as error: + raise_if_run_interrupted(error) caught_repr = repr(error) autocontrol_logger.info("AC_try caught: %s", caught_repr) error_var = args.get("error_var") @@ -479,6 +483,7 @@ def __init__(self, executor: Any, branches: list) -> None: self._failures: list = [0] * len(branches) self._errors: Dict[int, str] = {} self._assertions: Dict[int, AutoControlAssertionException] = {} + self._interruptions: list[Optional[BaseException]] = [None] * len(branches) def _branch_executor(self) -> Any: branch_executor = self._module.Executor() @@ -511,16 +516,25 @@ def run_branch(self, index: int, branch: Any) -> None: self._failures[index] = self._module.recorded_failures() except AutoControlAssertionException as error: self._assertions[index] = error - except Exception as error: # noqa: BLE001 # reason: see comment above - self._errors[index] = repr(error) - with bound_secret_scope(self._secret_scope): - autocontrol_logger.error( - "AC_parallel branch %d failed: %r", index, error, exc_info=True) + except BaseException as error: # reason: return worker control signals to the caller after joining + if not isinstance(error, Exception) or is_run_interruption(error): + self._interruptions[index] = error + else: + self._errors[index] = repr(error) + with bound_secret_scope(self._secret_scope): + autocontrol_logger.error( + "AC_parallel branch %d failed: %r", index, error, exc_info=True) + + def _raise_interruption(self) -> None: + for interruption in self._interruptions: + if interruption is not None: + raise interruption def settle(self) -> None: """On the parent thread: count branch failures, then raise the first problem.""" for _ in range(sum(self._failures)): self._module._count_recorded_failure() + self._raise_interruption() if self._assertions: # Re-raised as itself so it propagates like a top-level assert. raise self._assertions[min(self._assertions)] diff --git a/je_auto_control/utils/hotkey/hotkey_daemon.py b/je_auto_control/utils/hotkey/hotkey_daemon.py index becbc0881..11af33800 100644 --- a/je_auto_control/utils/hotkey/hotkey_daemon.py +++ b/je_auto_control/utils/hotkey/hotkey_daemon.py @@ -26,9 +26,11 @@ from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) -from je_auto_control.utils.run_history.run_outcome import run_counting_failures +from je_auto_control.utils.run_history.run_outcome import ( + RunOutcome, finish_history_run, run_counting_failures, +) from je_auto_control.utils.run_history.history_store import ( - SOURCE_HOTKEY, STATUS_ERROR, STATUS_OK, default_history_store, + SOURCE_HOTKEY, STATUS_ERROR as STATUS_ERROR, STATUS_OK as STATUS_OK, default_history_store, ) MOD_ALT = 0x0001 @@ -222,24 +224,24 @@ def _fire_binding(self, binding_id: str) -> None: if match is None or not match.enabled: return run_id = _start_history(match) - status = STATUS_OK - error_text: Optional[str] = None + outcome = RunOutcome() history = HistoryBinding(default_history_store, run_id) if run_id is not None else None try: with match._authorization.scope(), bound_history(history): actions = read_executable_action_json(match.script_path) run_counting_failures(lambda: self._execute(actions)) - except Exception as error: # noqa: BLE001 # reason: this runs on the backend's listener thread; any escape ends every hotkey + except BaseException as error: # noqa: BLE001 # reason: this runs on the backend's listener thread; any escape ends every hotkey # AutoControlException covers the common cases — a missing/renamed # script (AutoControlJsonActionException) or an action that raises # (image/window not found). Without it the exception escaped the # backend run-loop and silently killed the whole hotkey daemon. - status = STATUS_ERROR - error_text = repr(error) + outcome.fail(error) + if outcome.hard_abort: + raise autocontrol_logger.error("hotkey %s failed: %r", match.combo, error) finally: - _finish_history(run_id, status, error_text) + _finish_history(run_id, outcome.status, outcome.error_text, outcome) with self._lock: match.fired += 1 @@ -255,14 +257,12 @@ def _start_history(match: HotkeyBinding) -> Optional[int]: def _finish_history(run_id: Optional[int], status: str, - error_text: Optional[str]) -> None: + error_text: Optional[str], outcome: Optional[RunOutcome] = None) -> None: if run_id is None: return try: - artifact = (capture_error_snapshot(run_id) - if status == STATUS_ERROR else None) - default_history_store.finish_run( - run_id, status, error_text, artifact_path=artifact) + result = outcome if outcome is not None else RunOutcome(status=status, error_text=error_text) + finish_history_run(default_history_store, run_id, result, capture_error_snapshot) except AutoControlException as error: autocontrol_logger.error("hotkey run %s: history not updated: %r", run_id, error) diff --git a/je_auto_control/utils/mcp_server/context.py b/je_auto_control/utils/mcp_server/context.py index 36569734a..d1387ce9b 100644 --- a/je_auto_control/utils/mcp_server/context.py +++ b/je_auto_control/utils/mcp_server/context.py @@ -9,7 +9,6 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional - class OperationCancelledError(RuntimeError): """Raised by :meth:`ToolCallContext.check_cancelled` when the client cancels.""" @@ -31,7 +30,7 @@ class ToolCallContext: request_id: Any progress_token: Any = None - notifier: Optional[Callable[[str, dict], None]] = field( + notifier: Optional[Callable[[str, dict[str, Any]], None]] = field( default=None, repr=False, ) cancelled_event: threading.Event = field(default_factory=threading.Event) @@ -57,7 +56,7 @@ def progress(self, value: float, total: Optional[float] = None, """ if self.progress_token is None or self.notifier is None: return - params: dict = { + params: dict[str, Any] = { "progressToken": self.progress_token, "progress": float(value), } diff --git a/je_auto_control/utils/observer/observer.py b/je_auto_control/utils/observer/observer.py index 067f0d8fd..2dfc625f0 100644 --- a/je_auto_control/utils/observer/observer.py +++ b/je_auto_control/utils/observer/observer.py @@ -21,7 +21,7 @@ from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization -from je_auto_control.utils.run_history.run_outcome import run_counting_failures +from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted, run_counting_failures from je_auto_control.utils.timeouts import clamp_poll_interval EVENT_APPEAR = "appear" @@ -147,6 +147,7 @@ def _evaluate(self, rule: WatchRule) -> Optional[Dict[str, Any]]: value = rule.predicate() event = _transition(rule.last, value) except Exception as error: # noqa: BLE001 # reason: logged; the other rules keep running + raise_if_run_interrupted(error) autocontrol_logger.info( "observer %r predicate error: %r", rule.name, error) return None @@ -169,6 +170,7 @@ def _fire(self, rule: WatchRule, event: str, value: Any) -> None: with rule._authorization.scope(): run_counting_failures(lambda: rule.on_event(event, value)) except Exception as error: # noqa: BLE001 # reason: logged; the other rules keep running + raise_if_run_interrupted(error) autocontrol_logger.info( "observer %r handler error: %r", rule.name, error) diff --git a/je_auto_control/utils/run_history/run_outcome.py b/je_auto_control/utils/run_history/run_outcome.py index 3130d7871..acef41568 100644 --- a/je_auto_control/utils/run_history/run_outcome.py +++ b/je_auto_control/utils/run_history/run_outcome.py @@ -10,9 +10,64 @@ Each such run also gets its own variable scope: a job's ``AC_set_var`` used to stay in the module executor for whichever job, trigger or hotkey fired next. """ -from typing import Any, Callable +from asyncio import CancelledError +from concurrent.futures import CancelledError as FutureCancelledError +from dataclasses import dataclass +from typing import Any, Callable, Optional from je_auto_control.utils.exception.exceptions import AutoControlActionException +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.run_history.history_store import HistoryStore, STATUS_ERROR, STATUS_OK +from je_auto_control.utils.script_vars.execution import execution_scope + + +def is_run_interruption(error: BaseException) -> bool: + """Whether an error represents an explicit cancellation or hard control signal.""" + # pylint: disable-next=import-outside-toplevel # reason: MCP imports the executor during facade setup + from je_auto_control.utils.mcp_server.context import OperationCancelledError + return isinstance(error, (KeyboardInterrupt, SystemExit, GeneratorExit, CancelledError, + FutureCancelledError, OperationCancelledError)) + + +def raise_if_run_interrupted(error: BaseException) -> None: + """Keep cancellation out of ordinary failure recovery and retry boundaries.""" + if is_run_interruption(error): + raise error + + +@dataclass +class RunOutcome: + """A run's final history state and permission to take an error snapshot.""" + + status: str = STATUS_OK + error_text: Optional[str] = None + hard_abort: bool = False + cancelled: bool = False + + def fail(self, error: BaseException) -> None: + """Record failure without formatting a pending hard/cancellation signal.""" + self.status = STATUS_ERROR + self.hard_abort = not isinstance(error, Exception) + self.cancelled = is_run_interruption(error) + self.error_text = (f"{type(error).__name__}: run interrupted" if self.hard_abort or self.cancelled + else repr(error)) + + @property + def should_capture(self) -> bool: + """Ordinary failures may capture; cancellation and hard exits may not.""" + return self.status == STATUS_ERROR and not self.hard_abort and not self.cancelled + + +def finish_history_run(store: HistoryStore, run_id: int, outcome: RunOutcome, + capture: Callable[[int], Optional[str]]) -> None: + """Settle history while preserving any hard interruption already propagating.""" + artifact = capture(run_id) if outcome.should_capture else None + try: + store.finish_run(run_id, outcome.status, outcome.error_text, artifact_path=artifact) + except Exception: # pylint: disable=broad-exception-caught # reason: preserve pending hard interruption + if not outcome.hard_abort: + raise + autocontrol_logger.error("run history completion unavailable; preserving hard interruption") def run_counting_failures(run: Callable[[], Any]) -> Any: @@ -22,10 +77,10 @@ def run_counting_failures(run: Callable[[], Any]) -> Any: the thread's counter here erased that caller's previous failures and could make the CLI report success. A per-run delta keeps both outcomes intact. """ + # pylint: disable-next=import-outside-toplevel # reason: executor imports this outcome helper from je_auto_control.utils.executor.action_executor import ( recorded_failures, ) - from je_auto_control.utils.script_vars.execution import execution_scope before = recorded_failures() with execution_scope(): result = run() diff --git a/je_auto_control/utils/scheduler/scheduler.py b/je_auto_control/utils/scheduler/scheduler.py index 646aa244f..d2ac43b3c 100644 --- a/je_auto_control/utils/scheduler/scheduler.py +++ b/je_auto_control/utils/scheduler/scheduler.py @@ -18,9 +18,11 @@ from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) -from je_auto_control.utils.run_history.run_outcome import run_counting_failures +from je_auto_control.utils.run_history.run_outcome import ( + RunOutcome, finish_history_run, run_counting_failures, +) from je_auto_control.utils.run_history.history_store import ( - SOURCE_SCHEDULER, STATUS_ERROR, STATUS_OK, default_history_store, + SOURCE_SCHEDULER, STATUS_ERROR as STATUS_ERROR, STATUS_OK as STATUS_OK, default_history_store, ) from je_auto_control.utils.scheduler.cron import ( CronExpression, next_match, parse_cron, @@ -235,34 +237,20 @@ def _fire(self, job: ScheduledJob, now_mono: float, now_wall: float) -> None: run_id = default_history_store.start_run( SOURCE_SCHEDULER, job.job_id, job.script_path, ) - status = STATUS_OK - error_text: Optional[str] = None + outcome = RunOutcome() try: with job._authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(job.script_path) run_counting_failures(lambda: self._execute(actions)) - # 一個排程工作失敗必須記錄為 STATUS_ERROR 並繼續輪詢,絕不能拖垮 - # 排程執行緒。原本的 tuple 漏掉 AutoControlException——它是幾乎所有 - # action 失敗(找不到視窗/圖片、輸入錯誤)的基底,直接繼承 - # Exception,不屬 OSError/ValueError/RuntimeError——所以任何正常失敗 - # 的排程工作都會讓執行緒無聲死亡,其餘所有排程從此停擺。 - # A failing scheduled job must be recorded as STATUS_ERROR and the loop - # must go on — it must never kill the scheduler thread. The previous - # tuple missed AutoControlException, the base of nearly every action - # failure (window/image not found, input error) and a direct Exception - # subclass, so any normally-failing job killed the thread silently and - # every other scheduled job stopped firing forever. - except Exception as error: # noqa: BLE001 # reason: see comment above - status = STATUS_ERROR - error_text = repr(error) + # Settle every failed run; hard control signals keep propagating. + except BaseException as error: # noqa: BLE001 # reason: see comment above + outcome.fail(error) + if outcome.hard_abort: + raise autocontrol_logger.error("scheduler job %s failed: %r", job.job_id, error) finally: - artifact = (capture_error_snapshot(run_id) - if status == STATUS_ERROR else None) - default_history_store.finish_run( - run_id, status, error_text, artifact_path=artifact, - ) + finish_history_run(default_history_store, run_id, outcome, capture_error_snapshot) with self._lock: live = self._jobs.get(job.job_id) if live is not job: diff --git a/je_auto_control/utils/triggers/email_trigger.py b/je_auto_control/utils/triggers/email_trigger.py index 81bfc72f8..1e62d4868 100644 --- a/je_auto_control/utils/triggers/email_trigger.py +++ b/je_auto_control/utils/triggers/email_trigger.py @@ -33,9 +33,11 @@ from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) -from je_auto_control.utils.run_history.run_outcome import run_counting_failures +from je_auto_control.utils.run_history.run_outcome import ( + RunOutcome, finish_history_run, run_counting_failures, +) from je_auto_control.utils.run_history.history_store import ( - SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, default_history_store, + SOURCE_TRIGGER, STATUS_ERROR as STATUS_ERROR, STATUS_OK as STATUS_OK, default_history_store, ) @@ -491,24 +493,18 @@ def _execute_with_history(self, trigger: EmailTrigger, SOURCE_TRIGGER, f"email:{trigger.trigger_id}", trigger.script_path, ) - status = STATUS_OK - error_text: Optional[str] = None + outcome = RunOutcome() try: with trigger._authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(trigger.script_path) run_counting_failures(lambda: self._executor(actions, payload)) # Any failure is recorded as STATUS_ERROR -- not a bogus # STATUS_OK from the finally below -- before re-raising. - except Exception as error: # noqa: BLE001 # reason: re-raised - status = STATUS_ERROR - error_text = repr(error) + except BaseException as error: # noqa: BLE001 # reason: re-raised + outcome.fail(error) raise finally: - artifact = (capture_error_snapshot(run_id) - if status == STATUS_ERROR else None) - default_history_store.finish_run( - run_id, status, error_text, artifact_path=artifact, - ) + finish_history_run(default_history_store, run_id, outcome, capture_error_snapshot) trigger.fired += 1 @staticmethod diff --git a/je_auto_control/utils/triggers/trigger_engine.py b/je_auto_control/utils/triggers/trigger_engine.py index d44edbf0e..03537914b 100644 --- a/je_auto_control/utils/triggers/trigger_engine.py +++ b/je_auto_control/utils/triggers/trigger_engine.py @@ -24,9 +24,11 @@ from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) -from je_auto_control.utils.run_history.run_outcome import run_counting_failures +from je_auto_control.utils.run_history.run_outcome import ( + RunOutcome, finish_history_run, run_counting_failures, +) from je_auto_control.utils.run_history.history_store import ( - SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, default_history_store, + SOURCE_TRIGGER, STATUS_ERROR as STATUS_ERROR, STATUS_OK as STATUS_OK, default_history_store, ) if TYPE_CHECKING: # imported lazily at runtime, where each trigger needs it @@ -372,33 +374,20 @@ def _fire(self, trigger: _TriggerBase, now: float) -> None: run_id = default_history_store.start_run( SOURCE_TRIGGER, trigger.trigger_id, trigger.script_path, ) - status = STATUS_OK - error_text: Optional[str] = None + outcome = RunOutcome() try: with authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(trigger.script_path) run_counting_failures(lambda: self._execute(actions)) - # 這裡刻意攔截所有例外:一個 trigger 失敗必須記錄成 STATUS_ERROR - # 並繼續,而不是拖垮輪詢執行緒。原本的 tuple 漏掉 - # AutoControlJsonActionException,所以光是改名 script 檔就會讓 - # 整個引擎永久停擺。 - # Deliberately broad: a failing trigger must be recorded as - # STATUS_ERROR and the loop must go on — which the finally below - # already assumes. The previous tuple (OSError, ValueError, - # RuntimeError) missed AutoControlJsonActionException, so merely - # renaming a trigger's script file killed the polling thread and - # silently stopped every other trigger with it. - except Exception as error: # noqa: BLE001 # reason: see comment above - status = STATUS_ERROR - error_text = repr(error) + # Isolate ordinary trigger failures, while preserving hard control flow. + except BaseException as error: # noqa: BLE001 # reason: see comment above + outcome.fail(error) + if outcome.hard_abort: + raise autocontrol_logger.error("trigger %s failed: %r", trigger.trigger_id, error) finally: - artifact = (capture_error_snapshot(run_id) - if status == STATUS_ERROR else None) - default_history_store.finish_run( - run_id, status, error_text, artifact_path=artifact, - ) + finish_history_run(default_history_store, run_id, outcome, capture_error_snapshot) with self._lock: live = self._triggers.get(trigger.trigger_id) # Not just "some trigger with this id": one removed and replaced diff --git a/je_auto_control/utils/triggers/webhook_server.py b/je_auto_control/utils/triggers/webhook_server.py index a9e245efe..9567f09f1 100644 --- a/je_auto_control/utils/triggers/webhook_server.py +++ b/je_auto_control/utils/triggers/webhook_server.py @@ -48,9 +48,11 @@ from je_auto_control.utils.run_history.artifact_manager import ( capture_error_snapshot, ) -from je_auto_control.utils.run_history.run_outcome import run_counting_failures +from je_auto_control.utils.run_history.run_outcome import ( + RunOutcome, finish_history_run, run_counting_failures, +) from je_auto_control.utils.run_history.history_store import ( - SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, default_history_store, + SOURCE_TRIGGER, STATUS_ERROR as STATUS_ERROR, STATUS_OK as STATUS_OK, default_history_store, ) @@ -383,20 +385,16 @@ def fire(self, trigger: WebhookTrigger, SOURCE_TRIGGER, f"webhook:{trigger.webhook_id}", trigger.script_path, ) - status = STATUS_OK - error_text: Optional[str] = None - # Missing the framework base let a bad/renamed script escape fire() - # and propagate out of the HTTP handler: the run was recorded as a - # bogus STATUS_OK and the client's connection was dropped with no - # response. Catch the whole family so the fire is recorded as an - # error and _dispatch still answers the request. + outcome = RunOutcome() + # Settle failures before answering; hard exits propagate after finalization. try: with trigger._authorization.scope(), bound_history(HistoryBinding(default_history_store, run_id)): actions = read_executable_action_json(trigger.script_path) run_counting_failures(lambda: self._executor(actions, payload)) - except Exception as error: # noqa: BLE001 # reason: any script failure must be recorded and answered - status = STATUS_ERROR - error_text = repr(error) + except BaseException as error: # noqa: BLE001 # reason: any script failure must be recorded and answered + outcome.fail(error) + if outcome.hard_abort: + raise autocontrol_logger.error("webhook %s failed: %r", trigger.webhook_id, error) if isinstance(error, AuthorizationError): @@ -405,16 +403,12 @@ def fire(self, trigger: WebhookTrigger, trigger.last_status = int(HTTPStatus.FORBIDDEN) raise finally: - artifact = (capture_error_snapshot(run_id) - if status == STATUS_ERROR else None) - default_history_store.finish_run( - run_id, status, error_text, artifact_path=artifact, - ) + finish_history_run(default_history_store, run_id, outcome, capture_error_snapshot) with self._lock: live = self._triggers.get(trigger.webhook_id) if live is not None: live.fired += 1 - live.last_status = 200 if status == STATUS_OK else 500 + live.last_status = 200 if outcome.status == STATUS_OK else 500 return run_id finally: self._fire_lock.release() diff --git a/je_auto_control/utils/watchdog/popup_watchdog.py b/je_auto_control/utils/watchdog/popup_watchdog.py index 621b754d5..293e28883 100644 --- a/je_auto_control/utils/watchdog/popup_watchdog.py +++ b/je_auto_control/utils/watchdog/popup_watchdog.py @@ -23,7 +23,7 @@ from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.rbac.deferred import DeferredAuthorization -from je_auto_control.utils.run_history.run_outcome import run_counting_failures +from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted, run_counting_failures from je_auto_control.utils.timeouts import clamp_poll_interval # Errors a rule's matcher/action may raise that must not kill the guard loop @@ -155,6 +155,7 @@ def _apply(self, rule: WatchdogRule, # off the list (subprocess.TimeoutExpired, sqlite3.Error) killed the # guard thread and every other rule with it. except Exception as error: # noqa: BLE001 # reason: logged; one rule must not stop the others + raise_if_run_interrupted(error) autocontrol_logger.info( "popup watchdog rule %r error: %r", rule.name, error) return False diff --git a/test/unit_test/headless/test_history_interruption.py b/test/unit_test/headless/test_history_interruption.py new file mode 100644 index 000000000..9a646c86c --- /dev/null +++ b/test/unit_test/headless/test_history_interruption.py @@ -0,0 +1,204 @@ +"""Interrupted runs never look successful or drive a later screenshot/action.""" +import asyncio +import json +from concurrent.futures import CancelledError as FutureCancelledError + +import pytest + +from je_auto_control.utils.action_journal import ActionJournal, journal_scope, read_events +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.mcp_server.context import OperationCancelledError +from je_auto_control.utils.run_history.history_store import ( + HistoryStore, HistoryStoreError, STATUS_ERROR, STATUS_RUNNING, +) + +KINDS = ("scheduler", "trigger", "hotkey", "webhook", "email") +SIGNALS = (KeyboardInterrupt, SystemExit, GeneratorExit, asyncio.CancelledError, + FutureCancelledError, OperationCancelledError) + + +@pytest.fixture +def environment(tmp_path, monkeypatch): + from je_auto_control.utils.hotkey import hotkey_daemon + from je_auto_control.utils.scheduler import scheduler + from je_auto_control.utils.triggers import email_trigger, trigger_engine, webhook_server + history, snapshots = HistoryStore(), [] + for module in (hotkey_daemon, scheduler, email_trigger, trigger_engine, webhook_server): + monkeypatch.setattr(module, "default_history_store", history) + monkeypatch.setattr(module, "capture_error_snapshot", lambda run: snapshots.append(run)) + script = tmp_path / "actions.json" + script.write_text(json.dumps([["AC_interruption_probe"], ["AC_after_interruption"]]), encoding="utf-8") + try: + yield history, snapshots, script, tmp_path / "events.jsonl" + finally: + history.close() + + +def _fire(kind, script, execute): + if kind == "scheduler": + from je_auto_control.utils.scheduler.scheduler import Scheduler + owner = Scheduler(executor=execute) + job = owner.add_job(str(script), 60) + return lambda: owner._fire(job, 0, 1_800_000_000) + if kind == "trigger": + from je_auto_control.utils.triggers.trigger_engine import FilePathTrigger, TriggerEngine + owner = TriggerEngine(executor=execute) + trigger = FilePathTrigger("probe", str(script)) + owner.add(trigger) + return lambda: owner._fire(trigger, 0) + if kind == "hotkey": + from je_auto_control.utils.hotkey.hotkey_daemon import HotkeyDaemon + owner = HotkeyDaemon(executor=execute) + owner.bind("ctrl+alt+a", str(script), binding_id="probe") + return lambda: owner._fire_binding("probe") + if kind == "webhook": + from je_auto_control.utils.triggers.webhook_server import WebhookTriggerServer + owner = WebhookTriggerServer(executor=lambda actions, _variables: execute(actions)) + trigger = owner.add(path="/probe", script_path=str(script)) + return lambda: owner.fire(trigger, {}) + from je_auto_control.utils.triggers.email_trigger import EmailTriggerWatcher + owner = EmailTriggerWatcher(executor=lambda actions, _variables: execute(actions)) + trigger = owner.add("imap.invalid", "user", "unused", str(script)) + return lambda: owner._execute_with_history(trigger, {}) + + +def _execution(environment, monkeypatch, failure): + _history, _snapshots, script, path = environment + reached = [] + def fail(): + raise failure + monkeypatch.setitem(executor.event_dict, "AC_interruption_probe", fail) + monkeypatch.setitem(executor.event_dict, "AC_after_interruption", lambda: reached.append(True)) + def execute(actions): + with journal_scope(ActionJournal(path), run_id="interrupted"): + return executor.execute_action(actions) + return execute, reached, script, path + + +@pytest.mark.parametrize("kind", KINDS) +@pytest.mark.parametrize("signal", SIGNALS) +def test_interruption_records_failure_and_never_captures_or_runs_the_next_action( + kind, signal, environment, monkeypatch): + history, snapshots, _script, _path = environment + failure = signal("controlled") + execute, reached, script, path = _execution(environment, monkeypatch, failure) + fire = _fire(kind, script, execute) + if not isinstance(failure, Exception) or kind == "email": + with pytest.raises(signal) as raised: + fire() + assert raised.value is failure + else: + fire() + row = history.list_runs()[0] + assert row.status == STATUS_ERROR and type(failure).__name__ in row.error_text + assert snapshots == [] and reached == [] + assert len(history.journals_for_run(row.id)) == 1 + assert read_events(path)[-1].status == "cancelled" + + +@pytest.mark.parametrize("kind", KINDS) +def test_history_outage_cannot_replace_a_pending_hard_interruption(kind, environment, monkeypatch): + history, snapshots, _script, _path = environment + failure = KeyboardInterrupt("original control flow") + execute, reached, script, path = _execution(environment, monkeypatch, failure) + def unavailable(*_args, **_kwargs): + raise HistoryStoreError("controlled history outage") + monkeypatch.setattr(history, "finish_run", unavailable) + with pytest.raises(KeyboardInterrupt) as raised: + _fire(kind, script, execute)() + assert raised.value is failure + assert history.list_runs()[0].status == STATUS_RUNNING + assert snapshots == [] and reached == [] and read_events(path)[-1].status == "cancelled" + + +@pytest.mark.parametrize("kind", KINDS) +def test_ordinary_soft_failures_still_capture_and_keep_their_existing_containment(kind, environment, monkeypatch): + history, snapshots, _script, _path = environment + failure = ValueError("ordinary failure") + execute, reached, script, _path = _execution(environment, monkeypatch, failure) + fire = _fire(kind, script, execute) + if kind == "email": + with pytest.raises(AutoControlException): + fire() + else: + fire() + assert history.list_runs()[0].status == STATUS_ERROR + assert len(snapshots) == 1 and reached == [True] + + +def test_mcp_cancellation_keeps_its_deliberate_control_signal_contract(): + failure = OperationCancelledError("controlled") + assert isinstance(failure, RuntimeError) and not isinstance(failure, AutoControlException) + + +@pytest.mark.parametrize("block", ["parallel", "dag"]) +@pytest.mark.parametrize("signal", SIGNALS) +def test_worker_interruptions_reach_the_owner_without_becoming_success_or_an_ordinary_failure( + block, signal, environment, monkeypatch): + history, snapshots, script, path = environment + failure = signal("controlled") + execute, reached, _script, _path = _execution(environment, monkeypatch, failure) + body = [["AC_interruption_probe"], ["AC_after_interruption"]] + actions = ([["AC_parallel", {"branches": [body]}]] if block == "parallel" else + [["AC_run_dag", {"definition": {"nodes": [{"id": "first", "actions": body}, + {"id": "later", "depends_on": ["first"], "actions": [["AC_after_interruption"]]}]}}]]) + script.write_text(json.dumps(actions), encoding="utf-8") + fire = _fire("scheduler", script, execute) + if not isinstance(failure, Exception): + with pytest.raises(signal) as raised: + fire() + assert raised.value is failure + else: + fire() + assert history.list_runs()[0].status == STATUS_ERROR + assert snapshots == [] and reached == [] + assert read_events(path)[-1].status == "cancelled" + + +@pytest.mark.parametrize("boundary", ["callback_trigger", "callback_handler", "watchdog_matcher", "watchdog_action", + "observer_predicate", "observer_handler", "try", "retry", "image_condition"]) +@pytest.mark.parametrize("signal", [FutureCancelledError, OperationCancelledError]) +def test_nested_recovery_and_callback_boundaries_cannot_swallow_cancellation( + boundary, signal, environment, monkeypatch): + history, snapshots, script, path = environment + failure = signal("controlled") + execute, reached, _script, _path = _execution(environment, monkeypatch, failure) + def probe(*_args): + return executor.execute_action([["AC_interruption_probe"]]) + if boundary.startswith("callback"): + from je_auto_control.utils.callback.callback_function_executor import CallbackFunctionExecutor + owner = CallbackFunctionExecutor() + owner.event_dict["AC_probe"] = probe if boundary == "callback_trigger" else lambda: None + def invoke(): + return owner.callback_function("AC_probe", probe if boundary == "callback_handler" else lambda: None) + elif boundary.startswith("watchdog"): + from je_auto_control.utils.watchdog.popup_watchdog import PopupWatchdog, WatchdogRule + owner = PopupWatchdog() + owner.add_rule(WatchdogRule("probe", probe if boundary == "watchdog_matcher" else lambda: True, probe)) + invoke = owner.check_once + elif boundary.startswith("observer"): + from je_auto_control.utils.observer.observer import ScreenObserver + owner = ScreenObserver() + owner.add("probe", probe if boundary == "observer_predicate" else lambda: True, probe) + invoke = owner.poll_once + else: + def invoke(): + return None + if boundary == "try": + actions = [["AC_try", {"body": [["AC_interruption_probe"]], "catch": [["AC_after_interruption"]]}]] + elif boundary == "retry": + actions = [["AC_retry", {"body": [["AC_interruption_probe"]], "max_attempts": 2, "backoff": 0}]] + elif boundary == "image_condition": + from je_auto_control.utils.executor import flow_control + monkeypatch.setattr(flow_control, "locate_image_center", probe) + actions = [["AC_if_image_found", {"image": "unused.png", "then": [["AC_after_interruption"]], + "else": [["AC_after_interruption"]]}]] + else: + monkeypatch.setitem(executor.event_dict, "AC_control_boundary", invoke) + actions = [["AC_control_boundary"]] + script.write_text(json.dumps(actions), encoding="utf-8") + _fire("scheduler", script, execute)() + assert history.list_runs()[0].status == STATUS_ERROR + assert snapshots == [] and reached == [] + assert read_events(path)[-1].status == "cancelled" From 7eac43adbab1fcd90277a457a33bf5d279cf4970 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 00:09:09 +0800 Subject: [PATCH 018/294] Keep DAG node diagnostics inside their owning secret mask --- CHANGELOG.md | 4 ++ Progress.md | 1 - README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 4 +- architecture_explore.md | 12 ++-- .../Eng/doc/new_features/new_features_doc.rst | 3 + .../Zh/doc/new_features/new_features_doc.rst | 4 +- docs/updates/2026-10.md | 10 +++ docs/updates/README.md | 3 +- je_auto_control/utils/dag/runner.py | 64 ++++++++++--------- .../headless/test_dag_diagnostic_scope.py | 44 +++++++++++++ 13 files changed, 113 insertions(+), 42 deletions(-) create mode 100644 test/unit_test/headless/test_dag_diagnostic_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b2b08d5..737080761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- DAG node error diagnostics retain the owning run's resolved-secret mask after + the runner exits. Standalone DAG calls and custom local runners receive the + same protection; returned node-error data and run outcomes stay unchanged. + - Interrupted background attempts now settle history as `error` and skip error screenshots. Hard exits retain their original control flow even if history completion fails. Cancellation bypasses non-strict action failure recording, diff --git a/Progress.md b/Progress.md index 2d123167a..cfe32b05f 100644 --- a/Progress.md +++ b/Progress.md @@ -222,7 +222,6 @@ U-20261009-06 與 -04 的 macOS 部分是在 Windows 上對假的 pyobjc/Quart - **USB passthrough**:client 仍在 `reply_timeout_s`(10 秒)放棄,即使呼叫端要的 `timeout_ms` 更長(host 接受到 60 秒); 對新 host 逾時已經無害,對舊 host 正常使用就會進入「需重連」。通道層的 ERROR(passthrough 關閉、bad frame、host 沒有 session)在 session 之前送出、不帶請求編號, 呼叫仍是等到逾時。沒對真的舊版 host、真的 WebRTC 通道或真的 USB 裝置跑過。 -- **DAG 工作執行緒的診斷遮罩**(`TODO`):`utils/dag/runner.py::_run_one` 的一般失敗日誌在 runner 的遮罩退出後輸出,仍會留下解析後的秘密;需涵蓋 node 的完整執行與錯誤處理,並驗證直接呼叫與自訂 runner。 - **變數範圍**:free-threaded 原生執行緒繼承與同時存取範圍的行為尚未實測。 - **遠端桌面的擁有者模型**:Quick Connect 沒有狀態列,它的連線被取代時視窗只是關掉;主機的 Stop 會停任何擁有者開的 host(刻意的); 只用假的 viewer/host 測過,真的 viewer 被擠掉時會不會觸發 `on_error` 而多跳一個警告框,沒看過。 diff --git a/README.md b/README.md index 2e513396a..00042b410 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. Facade, command, CLI/MCP/GUI and manual-run history controls remain pending integration. -Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. Interrupted history runs are recorded as `error`, while journal steps remain `cancelled`; no error screenshot is taken. Cancellation propagates through non-strict execution, recovery/retry, callbacks and local workers. Hard exits retain their original signal even if history completion fails. +Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. DAG calls retain the owner mask through complete node diagnostics, including standalone calls and custom local runners. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. Interrupted history runs are recorded as `error`, while journal steps remain `cancelled`; no error screenshot is taken. Cancellation propagates through non-strict execution, recovery/retry, callbacks and local workers. Hard exits retain their original signal even if history completion fails. ## Command-line interface diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 45d5cc25a..eb397219d 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -247,7 +247,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。门面、动作命令、CLI/MCP/GUI 与手动执行的 history 控制仍待接入。 -`${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。中止的 history 记为 `error`,journal 步骤保持 `cancelled`,不再获取错误截图。取消会穿过非严格执行、恢复/重试、回调与本机 worker;history 结算失败也不会取代原始硬中止信号。 +`${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。DAG 会将拥有者的遮罩保留到 node 诊断完成,包括独立调用与自定义 local runner。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。中止的 history 记为 `error`,journal 步骤保持 `cancelled`,不再获取错误截图。取消会穿过非严格执行、恢复/重试、回调与本机 worker;history 结算失败也不会取代原始硬中止信号。 ## 命令行界面 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index a4506f8ce..5eca94429 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -246,7 +246,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。門面、動作指令、CLI/MCP/GUI 與手動執行的 history 控制仍待接入。 -`${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。中止的 history 記為 `error`,journal 步驟維持 `cancelled`,不再擷取錯誤截圖。取消會穿過非嚴格執行、復原/重試、回呼與本機 worker;history 結算失敗也不會取代原始硬中止訊號。 +`${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。DAG 會將擁有者的遮罩保留到 node 診斷完成,包含獨立呼叫與自訂 local runner。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。中止的 history 記為 `error`,journal 步驟維持 `cancelled`,不再擷取錯誤截圖。取消會穿過非嚴格執行、復原/重試、回呼與本機 worker;history 結算失敗也不會取代原始硬中止訊號。 ## 命令列介面 diff --git a/architecture.md b/architecture.md index 7cb671a1c..bd1add4a4 100644 --- a/architecture.md +++ b/architecture.md @@ -65,7 +65,9 @@ mask text before storage. Joined local parallel/DAG workers share the mask; independent requests isolate it, and the owner clears it on exit. API outcomes retain their values; CLI error output is sanitized. Watchdog/observer rules and callback-executor invocations own their complete diagnostic scope; nested -invocations reuse the enclosing run mask. +invocations reuse the enclosing run mask. DAG calls also own a mask; each +node worker binds it through runner execution, error handling and final timing, +including custom local runners. Returned error payloads retain their values. **A. JSON action script (primary path)** diff --git a/architecture_explore.md b/architecture_explore.md index 2b1379417..a05e112f9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -6,7 +6,7 @@ > 擷取每個模組的 docstring 與頂層公開名稱;統計數字取自實際檔案,非估算。 > 指令數與公開 API 數以 `executor.known_commands()` 與 `je_auto_control.__all__` 在工作樹上實測取得。 > -> **掃描時間**:2026-10-18 **版本**:`pyproject.toml` version `0.0.228` **分支**:`dev` +> **掃描時間**:2026-10-19 **版本**:`pyproject.toml` version `0.0.228` **分支**:`dev` --- @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,087 | -| 程式碼總行數 | 163,047 | +| 程式碼總行數 | 163,053 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,878 行。 +> 25 個套件、約 15,884 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -280,7 +280,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/action_signing/` | 716 | action 檔 HMAC-SHA256 簽章與 Fernet 加密,`execute_files` 會強制驗簽 | | `utils/checkpoint/` | 129 | 流程檢查點與續跑,讓長 action list 具持久性 | | `utils/codegen/` | 294 | 由 action list 產生可執行的 pytest / python / robot 測試碼 | -| `utils/dag/` | 578 | 跨主機 DAG 編排器(圖模型 + runner) | +| `utils/dag/` | 584 | 跨主機 DAG 編排器;node 的完整錯誤處理綁定擁有者秘密遮罩,含獨立呼叫與自訂 local runner | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | | `utils/action_journal/` | 587 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | @@ -1102,6 +1102,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 697 | 59,673 | -| **總計** | **1,081** | **162,982** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 697 | 59,679 | +| **總計** | **1,081** | **162,988** | diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 742126ccb..3813bbf3e 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1354,6 +1354,9 @@ Python/JSON escaped forms are masked. Nested actions and joined local parallel/DAG workers share the run's mask; concurrent independent runs have separate masks. CLI/REST/MCP requests and scheduled, trigger, hotkey and email owners keep their mask through outer error handling and audit/history writes. +Standalone DAG calls own a mask too. Each node worker binds the owner mask +through runner execution, outcome handling, final error logging and timing, +including custom local runners. Returned node-error payloads remain unchanged. Independent watchdog/observer rules and callback-executor invocations keep a mask through their matcher/predicate, trigger, callback and final error log. Nested calls reuse the enclosing run's mask; independent rules and subsequent diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index fd05697bd..368d9892f 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1257,7 +1257,9 @@ executor 執行期間,解析後的 ``${secrets.NAME}`` 值會在送到 handler 遮罩包含原始值與常見 Python/JSON 跳脫形式。巢狀動作及等候完成的本機 parallel/DAG 工作執行緒共用當次執行的遮罩;各個獨立執行彼此隔離。 CLI/REST/MCP 請求與排程、trigger、hotkey、email 擁有者會將遮罩保留到 -外層錯誤處理及 audit/history 寫入完成。獨立的 watchdog/observer 規則與 +外層錯誤處理及 audit/history 寫入完成。獨立的 DAG 呼叫也會建立遮罩;每個 node +worker 將擁有者的遮罩保留到 runner 執行、結果處理、最後的錯誤日誌與計時完成, +包含自訂 local runner。回傳的 node 錯誤資料仍保留原值。獨立的 watchdog/observer 規則與 callback executor 呼叫會將遮罩保留到 matcher/predicate、trigger、callback 及 最後的錯誤日誌結束;巢狀呼叫沿用外層範圍,獨立規則與後續呼叫各自清除資料。 CLI stderr 也會遮罩。報表紀錄在遮罩 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 45fde9238..3e2c3fafd 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -406,6 +406,8 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Files**: new `utils/logging/secret_scope.py`, logger setup, interpolation, executor dispatch, parallel/DAG bindings, regression tests, three READMEs, Sphinx vault references, changelog and measured architecture maps. - **Open items**: outer CLI/REST/MCP exception logging and persistent audit/history boundaries remain in `Progress.md`, alongside B1 facade/command/CLI/MCP/GUI surfaces and run-history linking. Native and paid-API acceptance remain deferred. +→ corrected in U-20261019-01. + ## U-20261018-14 · 2026-10-18 · Isolate the legacy CLI dispatch boundary without changing flags · #done #cli #refactor - **What**: moved the legacy entry point's flag dispatch and error conversion into private `_main()`, with the three action-file/directory/string helpers at module level. The `__main__` guard still invokes the same pipeline. This supplies one explicit boundary for the follow-up secret-mask lifecycle change without mixing that behavior into the refactor. @@ -453,3 +455,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: the full suite passes 11,760 tests with 69 skipped and 87.77% coverage. 71 new cases cover six interruption classes across five owners, history outages while hard signals propagate, ordinary-failure behavior, both local worker types, retry/recovery/image conditions and callback boundaries. The related journal/history/deferred/callback/flow selection passes 313 tests; 44 documentation, file-budget, complexity, exception and light-import checks pass. Three touched modules pass strict mypy; whole-package three-platform typing, ruff and bandit pass. Outcome/context code scores 10.00/10 in pylint. Strict English/Chinese Sphinx and wheel/source builds pass, and all 14 changed production modules match the wheel. Jeffrey_RPA's facade/control suites pass 751 tests against this tree (five existing Discord deprecations). - **Files**: run outcome/finalization, five history owners, executor/flow/DAG propagation, callback/observer/watchdog guards, journal capture, MCP context annotations, regression tests, three READMEs, Sphinx journal guide, changelog and measured architecture maps. - **Open items**: B1 operation surfaces/manual history wiring, the separately reproduced DAG node diagnostic-mask lifetime gap and F3 shared-worker cancellation remain in `Progress.md`; native and paid-API acceptance remain deferred. + +## U-20261019-01 · 2026-10-19 · Keep complete DAG node diagnostics inside their owner mask · #incident #security #dag + +- **Finding**: ordinary DAG node errors were logged after the default runner's secret binding exited. Direct DAG calls also had no enclosing run mask. Controlled fake-vault tests reproduced plaintext in node diagnostics for default, enclosing-scope and custom local runner paths. +- **Correction**: DAG calls own/reuse a run mask and pass it to each node worker. The worker keeps that binding through runner invocation, outcome/error handling and final timing. Existing enclosing masks survive the nested call; the owner clears values on exit. Returned node-error payloads and failure/cancellation behavior remain unchanged. +- **Validation**: all three new reproductions pass after failing on the old boundary; the related DAG/journal/history/callback/deferred/flow selection passes 265 tests. Three-platform whole-package mypy, ruff and bandit pass; 44 documentation, complexity, budget, exception and import checks pass. Strict English/Chinese Sphinx and wheel/source builds pass; the packaged DAG runner matches the source. Pylint retains legacy findings (9.00/10); radon reports no function above complexity B. +- **Files**: `utils/dag/runner.py`, diagnostic-scope regressions, three READMEs, Sphinx vault reference, changelog and architecture maps. Completes the node-error diagnostic boundary missing from U-20261018-13's worker-mask coverage. +- **Open items**: B1 operation surfaces/manual history wiring and F3 shared-worker cancellation remain in `Progress.md`; native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index 1384e7540..989b0052f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261019-01 | 2026-10-19 | Keep complete DAG node diagnostics inside their owner mask | #incident #security #dag | [2026-10](2026-10.md) | | U-20261018-19 | 2026-10-18 | Preserve interrupted execution through history, workers and recovery guards | #done #executor #cancellation | [2026-10](2026-10.md) | | U-20261018-18 | 2026-10-18 | Link explicit journals to existing run-history owners | #done #journal #history | [2026-10](2026-10.md) | | U-20261018-17 | 2026-10-18 | Release SQLite connections after failed history initialization | #incident #history #storage | [2026-10](2026-10.md) | @@ -382,7 +383,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 46 | +| [2026-10.md](2026-10.md) | 2026-10 | 47 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/dag/runner.py b/je_auto_control/utils/dag/runner.py index 593c20161..634132e30 100644 --- a/je_auto_control/utils/dag/runner.py +++ b/je_auto_control/utils/dag/runner.py @@ -20,6 +20,9 @@ from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.run_history.run_outcome import is_run_interruption from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import ( + SecretLogScope, bound_secret_scope, current_secret_scope, secret_log_scope, +) STATUS_PENDING = "pending" @@ -76,6 +79,7 @@ def to_dict(self) -> Dict[str, Any]: NodeRunner = Callable[[DagNode, DagDefinition], Any] +@secret_log_scope() def run_dag(definition: Any, *, max_parallel: int = 4, @@ -180,7 +184,7 @@ def _spawn_ready_nodes(pending: Set[str], inflight: Dict[Future, str], results[nid].status = STATUS_RUNNING results[nid].started_at = time.monotonic() runner = local if node.host == LOCAL_HOST else remote - future = pool.submit(_run_one, node, results[nid], runner, nodes_by_id) + future = pool.submit(_run_one, node, results[nid], runner, nodes_by_id, current_secret_scope()) inflight[future] = nid pending.discard(nid) @@ -232,33 +236,36 @@ def _record_future(nid: str, finished: Future, results[nid].error = repr(error) -def _run_one(node: DagNode, result: NodeResult, - runner: NodeRunner, _nodes: Dict[str, DagNode]) -> None: - try: - outcome = runner(node, _build_proxy_definition(node, _nodes)) - except _NodeStopped: - result.status = STATUS_SKIPPED - result.error = "stopped" - # A runner is user code and may raise any Exception subclass; one that - # escaped left the node "running" and run_dag returned no result. - except BaseException as error: # reason: settle node state; cancellation/control signals re-raise - # AutoControlException covers validate_actions / locate / assert - # failures raised by the executor: a node failure becomes a failed - # NodeResult (and a downstream skip cascade), not a raw crash that - # leaves the node stuck "running" and returns no DagRunResult. - result.status = STATUS_FAILED - if not isinstance(error, Exception) or is_run_interruption(error): - result.error = f"{type(error).__name__}: run interrupted" - raise - result.error = f"{type(error).__name__}: {error}" - autocontrol_logger.warning( - f"DAG node {node.id!r} failed: {result.error}", - ) - else: - result.status = STATUS_SUCCEEDED - result.result = outcome - finally: - _finish_node_timing(result) +def _run_one(node: DagNode, result: NodeResult, runner: NodeRunner, _nodes: Dict[str, DagNode], + secret_scope: Optional[SecretLogScope] = None) -> None: + """Keep node execution, outcome handling and final diagnostics in one mask.""" + binding = current_secret_scope() if secret_scope is None else secret_scope + with bound_secret_scope(binding): + try: + outcome = runner(node, _build_proxy_definition(node, _nodes)) + except _NodeStopped: + result.status = STATUS_SKIPPED + result.error = "stopped" + # A runner is user code and may raise any Exception subclass; one that + # escaped left the node "running" and run_dag returned no result. + except BaseException as error: # reason: settle node state; cancellation/control signals re-raise + # AutoControlException covers validate_actions / locate / assert + # failures raised by the executor: a node failure becomes a failed + # NodeResult (and a downstream skip cascade), not a raw crash that + # leaves the node stuck "running" and returns no DagRunResult. + result.status = STATUS_FAILED + if not isinstance(error, Exception) or is_run_interruption(error): + result.error = f"{type(error).__name__}: run interrupted" + raise + result.error = f"{type(error).__name__}: {error}" + autocontrol_logger.warning( + f"DAG node {node.id!r} failed: {result.error}", + ) + else: + result.status = STATUS_SUCCEEDED + result.result = outcome + finally: + _finish_node_timing(result) def _finish_node_timing(result: NodeResult) -> None: @@ -315,7 +322,6 @@ def _current_run_local_runner() -> NodeRunner: from je_auto_control.utils.executor.action_executor import _running_executor from je_auto_control.utils.script_vars.execution import bound_scope from je_auto_control.utils.action_journal.capture import bound_journal, current_journal - from je_auto_control.utils.logging.secret_scope import bound_secret_scope, current_secret_scope from je_auto_control.utils.run_history.context import bound_history, current_history runner = _running_executor() scope = runner.variables diff --git a/test/unit_test/headless/test_dag_diagnostic_scope.py b/test/unit_test/headless/test_dag_diagnostic_scope.py new file mode 100644 index 000000000..92af54d12 --- /dev/null +++ b/test/unit_test/headless/test_dag_diagnostic_scope.py @@ -0,0 +1,44 @@ +"""DAG node diagnostics stay masked after a local runner returns or raises.""" +import io +import json +import logging + +import pytest + +from je_auto_control.utils.dag.runner import run_dag +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import current_secret_scope, secret_log_scope +from je_auto_control.utils.secrets import default_secret_manager + +FAKE = "fake-dag-node-private-value" + + +@pytest.mark.parametrize("mode", ["default", "enclosing", "custom"]) +def test_worker_diagnostic_uses_the_owner_mask_while_error_results_remain_intact(mode, monkeypatch): + def fail(value): + raise ValueError("controlled failure: " + value) + monkeypatch.setattr(default_secret_manager, "get", lambda _name: FAKE) + monkeypatch.setitem(executor.event_dict, "AC_dag_diagnostic_probe", fail) + actions = [["AC_dag_diagnostic_probe", {"value": "${secrets.LOGIN}"}]] + definition = {"nodes": [{"id": "probe", "actions": actions}]} + stream = io.StringIO() + handler = logging.StreamHandler(stream) + autocontrol_logger.addHandler(handler) + def custom(node, _definition): + return executor.execute_action(node.actions, raise_on_error=True) + try: + if mode == "enclosing": + with secret_log_scope(): + owner = current_secret_scope() + report = run_dag(definition) + assert current_secret_scope() is owner + else: + report = run_dag(definition, local_runner=custom if mode == "custom" else None) + finally: + autocontrol_logger.removeHandler(handler) + handler.close() + assert current_secret_scope() is None + assert FAKE in json.dumps(report.to_dict()) + assert FAKE not in stream.getvalue() + assert "DAG node 'probe' failed: ValueError: controlled failure: ***" in stream.getvalue() From 5d106a8d49cd033f914244b6c62c849fbf83de56 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 00:25:52 +0800 Subject: [PATCH 019/294] Expose journal capture and bounded CLI inspection --- CHANGELOG.md | 6 + Progress.md | 2 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 9 +- architecture_explore.md | 18 +- docs/API_LIFECYCLE.md | 12 +- docs/source/Eng/doc/cli/cli_doc.rst | 24 +++ .../Eng/doc/new_features/new_features_doc.rst | 28 +-- docs/source/Zh/doc/cli/cli_doc.rst | 19 ++ .../Zh/doc/new_features/new_features_doc.rst | 26 +-- docs/updates/2026-10.md | 8 + docs/updates/README.md | 3 +- je_auto_control/__init__.py | 11 +- je_auto_control/cli.py | 54 ++++- test/unit_test/headless/test_doc_counts.py | 13 +- .../headless/test_journal_public_cli.py | 195 ++++++++++++++++++ 18 files changed, 387 insertions(+), 47 deletions(-) create mode 100644 test/unit_test/headless/test_journal_public_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 737080761..5f648ad5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Structured journal types and capture/read helpers, plus history `JournalLink`, + are exported from `je_auto_control`. CLI `run --journal PATH` captures actions + with an optional `--journal-run-id`; `journal PATH` supports run filtering and + bounded, sanitized snapshot output. Existing run stdout and exit codes remain + unchanged; these operations create no history database or row implicitly. + - DAG node error diagnostics retain the owning run's resolved-secret mask after the runner exits. Standalone DAG calls and custom local runners receive the same protection; returned node-error data and run outcomes stay unchanged. diff --git a/Progress.md b/Progress.md index cfe32b05f..be80a38a6 100644 --- a/Progress.md +++ b/Progress.md @@ -14,7 +14,7 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的門面/動作指令/CLI/MCP/GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的動作指令/MCP/GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 diff --git a/README.md b/README.md index 00042b410..a2000fcd2 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. --- -Structured journal capture is available through explicit Python scopes in `utils.action_journal`: `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. Facade, command, CLI/MCP/GUI and manual-run history controls remain pending integration. +Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). Command, MCP/GUI and manual-run history controls remain pending integration. Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. DAG calls retain the owner mask through complete node diagnostics, including standalone calls and custom local runners. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. Interrupted history runs are recorded as `error`, while journal steps remain `cancelled`; no error screenshot is taken. Cancellation propagates through non-strict execution, recovery/retry, callbacks and local workers. Hard exits retain their original signal even if history completion fails. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index eb397219d..266b4bbb7 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -245,7 +245,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -结构化日志可通过 `utils.action_journal` 的显式 Python 作用域捕获:`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。门面、动作命令、CLI/MCP/GUI 与手动执行的 history 控制仍待接入。 +实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。动作命令、MCP/GUI 与手动执行的 history 控制仍待接入。 `${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。DAG 会将拥有者的遮罩保留到 node 诊断完成,包括独立调用与自定义 local runner。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。中止的 history 记为 `error`,journal 步骤保持 `cancelled`,不再获取错误截图。取消会穿过非严格执行、恢复/重试、回调与本机 worker;history 结算失败也不会取代原始硬中止信号。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 5eca94429..cc37f273d 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -244,7 +244,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -結構化日誌可透過 `utils.action_journal` 的明確 Python 範圍擷取:`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。門面、動作指令、CLI/MCP/GUI 與手動執行的 history 控制仍待接入。 +實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。動作指令、MCP/GUI 與手動執行的 history 控制仍待接入。 `${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。DAG 會將擁有者的遮罩保留到 node 診斷完成,包含獨立呼叫與自訂 local runner。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。中止的 history 記為 `error`,journal 步驟維持 `cancelled`,不再擷取錯誤截圖。取消會穿過非嚴格執行、復原/重試、回呼與本機 worker;history 結算失敗也不會取代原始硬中止訊號。 diff --git a/architecture.md b/architecture.md index bd1add4a4..fd5a7b961 100644 --- a/architecture.md +++ b/architecture.md @@ -125,8 +125,13 @@ steps and ignore bounded uncommitted tails; opaque outcomes/errors are redacted. Explicit `journal_scope` capture wraps each executor dispatch; loop bodies keep their step parent and local parallel/DAG workers rebind the same run metadata. `execute_action_with_journal` opens a fresh variable scope and returns a typed -run summary. Capture starts are durable before effects; failed actions, handled -loop signals, cancellation and dry-runs receive distinct completion statuses. +run summary. Journal types, capture/read helpers and `JournalLink` are exported +from the Qt-free facade. CLI `run --journal` retains the original record/exit +contract; `journal` validates a full file then emits a bounded, sanitized snapshot +selection. Both new CLI paths use the existing allowed-root guard and create no +history row or database implicitly. Capture starts are durable before effects; +failed actions, handled loop signals, cancellation and dry-runs receive distinct +completion statuses. `utils/run_history/context.py` binds an existing history row to execution. Scheduler/trigger/hotkey/webhook/email owners bind their own row; local parallel and DAG workers carry it explicitly. `journal_scope` attaches an idempotent diff --git a/architecture_explore.md b/architecture_explore.md index a05e112f9..4c84d23d6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,13 +20,13 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,087 | -| 程式碼總行數 | 163,053 | +| 程式碼總行數 | 163,110 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 779 | -| 套件門面 `__all__` 公開名稱數 | 1,244 | +| 套件門面 `__all__` 公開名稱數 | 1,273 | | GUI 分頁數(`main_widget` 註冊) | 48 | | MCP 工具數(`build_default_tool_registry()` 實測) | 680 | -| `test_*.py` 測試檔/測試函式 | 478 / 4,654 | +| `test_*.py` 測試檔/測試函式 | 761 / 8,016 | | 範例腳本 | 27 | **技術基線**:Python ≥ 3.10、MIT 授權、必要相依只有 `je_open_cv`/`opencv-python`/`pillow`/`mss`/ @@ -92,7 +92,7 @@ USB/IP 協定、Prometheus 指標),以維持這條輕相依基線。 | 模式 | 落點 | 說明 | | --- | --- | --- | | **Strategy** | `wrapper/platform_wrapper.py` | 依 `sys.platform` 只匯入當前 OS 的 `keyboard`/`mouse`/`screen`/`recorder` 實作;Linux 再細分 Wayland/X11,Wayland 後端不可用時自動退回 XWayland 並記警告。新增平台不需要改 wrapper。 | -| **Facade** | `je_auto_control/__init__.py` | 把 1,264 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | +| **Facade** | `je_auto_control/__init__.py` | 把 1,273 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | | **Command** | `utils/executor/action_executor.py` | `Executor.event_dict` 是字串 → callable 的分派表;JSON 動作檔即指令序列,因此可錄製、序列化、重播、簽章。 | | **Observer** | `utils/callback/`、`utils/observer/`、`utils/triggers/` | 動作完成後觸發回呼;畫面出現/消失/變化與外部事件(webhook/IMAP/檔案)驅動腳本。 | | **Template Method** | `utils/generate_report/` | HTML/JSON/XML 三個產生器共用「收集紀錄 → 格式化 → 寫檔」骨架,各自實作渲染。 | @@ -154,9 +154,9 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `je_auto_control/__init__.py` | 1,994 | **套件門面**。集中匯入並再匯出 1,264 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | +| `je_auto_control/__init__.py` | 2,001 | **套件門面**。集中匯入並再匯出 1,273 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | | `je_auto_control/__main__.py` | 106 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | -| `je_auto_control/cli.py` | 370 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | +| `je_auto_control/cli.py` | 420 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`/`--journal`)、`journal`(依 run 篩選與有界遮罩快照)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | | `je_auto_control/api/core.py` | 19 | **穩定無頭 API 門面**:只暴露 `execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、`create_failure_bundle`、`failure_bundle_on_error`、`FailureBundleOptions`。mypy 型別契約以此為起點,現已擴到整包(見「設定基線」)。 | | `je_auto_control/utils/cli_output.py` | 44 | 本套件命令列工具與 stdio 伺服器的標準串流:不論碼頁一律 UTF-8。 | @@ -283,7 +283,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/dag/` | 584 | 跨主機 DAG 編排器;node 的完整錯誤處理綁定擁有者秘密遮罩,含獨立呼叫與自訂 local runner | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | -| `utils/action_journal/` | 587 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;操作介面待接入。 | +| `utils/action_journal/` | 587 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;門面與 CLI 擷取/有界查閱已接入,其他操作介面待補。 | | `utils/executor/` | 9,700 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | @@ -1087,7 +1087,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,700 | | `utils/usb/` | 17 | 4,816 | -| `je_auto_control/`(頂層 3 檔) | 3 | 2,470 | +| `je_auto_control/`(頂層 3 檔) | 3 | 2,527 | | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | @@ -1103,5 +1103,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 697 | 59,679 | -| **總計** | **1,081** | **162,988** | +| **總計** | **1,081** | **163,045** | diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index 5e2705ff9..23ad6675c 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -3,7 +3,17 @@ The supported entry point for new integrations is `je_auto_control.api`. Everything reachable only through `je_auto_control.utils` is internal unless a document explicitly says otherwise. The historical top-level package remains -available for compatibility but is not expanded with new integrations. +a broad convenience facade and also exports new headless features under the +project's facade rule. Membership in its `__all__` does not promote a feature +into the versioned stable API. + +Structured action-journal storage, capture/read helpers and history provenance +links are experimental while B1 interface integration remains in progress. +The documented names are available through `je_auto_control` and +`je_auto_control.utils.action_journal`; `JournalLink` is also exported from +`je_auto_control.utils.run_history`. CLI journal capture/inspection uses the same +experimental schema-v1 snapshots. These additions do not change the lifecycle +of existing executor or history APIs. - Stable API removal requires a deprecation warning and two minor releases. - Beta API removal requires one release note and one minor release. diff --git a/docs/source/Eng/doc/cli/cli_doc.rst b/docs/source/Eng/doc/cli/cli_doc.rst index d15089916..191e19595 100644 --- a/docs/source/Eng/doc/cli/cli_doc.rst +++ b/docs/source/Eng/doc/cli/cli_doc.rst @@ -37,6 +37,30 @@ been allowed. The ``JE_AUTOCONTROL_ALLOWED_PACKAGES`` environment variable (comma-separated names) does the same for every entry point, including the legacy flags below, ``start-server``, ``start-rest`` and the MCP server. +Capture and inspect action journals +----------------------------------- + +.. code-block:: bash + + python -m je_auto_control.cli run script.json --journal run.jsonl --journal-run-id example + python -m je_auto_control.cli journal run.jsonl --run-id example --limit 100 + +``run --journal PATH`` appends structured lifecycle snapshots while preserving +the normal stdout record and action-failure exit code. It also supports +``--dry-run`` and ``--var``. Each capture generates a run ID unless +``--journal-run-id ID`` is supplied; that option requires ``--journal``. +No history row or database is created implicitly. + +``journal PATH`` validates the entire file and prints a JSON array of sanitized +snapshots in append order. ``--run-id`` filters a run; ``--limit`` bounds output +to 1–10,000 snapshots (default 100), after filtering. Validation still reads the +whole file. Start and completion snapshots remain separate; incomplete entries +are never inferred successful. A missing file returns an empty array without +creating it. Malformed committed records fail before any JSON output. +Reading applies the journal's masking policy again and never changes the file. +Both journal paths use the existing allowed-root guard; configure +``AUTOCONTROL_ALLOWED_PATH_ROOTS`` for destinations outside the default roots. + List scheduler jobs ------------------- diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 3813bbf3e..d66a050ad 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -363,7 +363,7 @@ JSON list with text, bounding box, and confidence per hit. Runtime variables and data-driven control flow ============================================== -Pre-execution interpolation in :mod:`script_vars.interpolate` only +Pre-execution interpolation in ``script_vars.interpolate`` only substituted ``${var}`` placeholders once against a static mapping; scripts had no way to mutate state during execution. ``VariableScope`` is a runtime mapping the executor exposes to flow-control commands so @@ -506,7 +506,7 @@ so the result can be piped straight into ``execute_action``:: # Or in one call: ac.run_from_description("open Notepad and type hello", executor=executor) -Backend selection mirrors :mod:`vision.backends`: +Backend selection mirrors ``vision.backends``: - Anthropic (``anthropic`` SDK, ``ANTHROPIC_API_KEY``) — default - ``AUTOCONTROL_LLM_BACKEND`` and ``AUTOCONTROL_LLM_MODEL`` for overrides @@ -574,7 +574,7 @@ Input message allowlist (validated on the host before dispatch): - ``ping`` Action-JSON commands (use the singleton in -:mod:`utils.remote_desktop.registry`):: +``utils.remote_desktop.registry``):: AC_start_remote_host # token, bind, port, fps, quality, region AC_stop_remote_host @@ -1025,7 +1025,7 @@ Remote desktop — AnyDesk-style popout window The viewer panel no longer renders the live remote screen inline — when the viewer authenticates, a dedicated top-level -:class:`RemoteScreenWindow` opens with the remote desktop, and the +``RemoteScreenWindow`` opens with the remote desktop, and the panel shrinks back to the connection card + controls. Closing the popup ✕ disconnects the session, matching AnyDesk's session-window ergonomics. @@ -1379,7 +1379,7 @@ read access to the owning user. Webhook (HTTP push) trigger =========================== -A bundled :mod:`http.server` dispatcher fires an action script when an +A bundled ``http.server`` dispatcher fires an action script when an external service POSTs to a registered path. Configure path, allowed methods (``GET``, ``POST``, ``PUT``, ``PATCH``, ``DELETE``; any other verb is refused when the webhook is added), and an optional bearer token; the request method, path, query, @@ -1415,7 +1415,7 @@ Each fire is recorded in run history as ``trigger`` with source id ``webhook:`` so the dashboard surfaces webhook activity alongside other triggers; a fire in which any action failed is recorded as an error (with an error snapshot), as scheduled, triggered and hotkey runs are. The body is capped at 1 MiB and bearer-token comparison -uses :func:`hmac.compare_digest`. Bind to ``127.0.0.1`` unless the +uses ``hmac.compare_digest``. Bind to ``127.0.0.1`` unless the listener genuinely needs to be reachable from elsewhere on the network. GUI: **Webhooks** tab — start/stop the server, register paths, view the @@ -1474,17 +1474,19 @@ with ``${secrets.NAME}`` so passwords never appear in the JSON. GUI: **Email Triggers** tab — register IMAP triggers, start/stop the watcher, run a manual poll, inspect last error and fire counter. -Structured action journal storage (integration pending) -======================================================= +Structured action journals (partial integration) +================================================ -B1 provides a storage foundation under ``je_auto_control.utils.action_journal``; -facade and command exports, CLI/MCP/GUI and manual-run history controls remain -pending integration. Applications can explicitly write schema-v1 snapshots:: +B1 exports storage and explicit capture/read APIs from ``je_auto_control`` and +``je_auto_control.utils.action_journal``. CLI capture and bounded snapshot reads +are available; command, MCP/GUI and manual-run history controls remain pending +integration. The journal/provenance APIs remain experimental during B1. +Applications can explicitly write schema-v1 snapshots:: import time from dataclasses import replace from pathlib import Path - from je_auto_control.utils.action_journal import ActionEvent, ActionJournal, read_events + from je_auto_control import ActionEvent, ActionJournal, read_events journal = ActionJournal(Path("actions.jsonl")) started = ActionEvent( @@ -1515,7 +1517,7 @@ and does not infer a completed step from a missing final record. Explicit executor capture is also available:: from pathlib import Path - from je_auto_control.utils.action_journal import execute_action_with_journal, read_events + from je_auto_control import execute_action_with_journal, read_events summary = execute_action_with_journal( [["AC_set_var", {"name": "count", "value": 1}]], diff --git a/docs/source/Zh/doc/cli/cli_doc.rst b/docs/source/Zh/doc/cli/cli_doc.rst index 501c8da1d..4eb005705 100644 --- a/docs/source/Zh/doc/cli/cli_doc.rst +++ b/docs/source/Zh/doc/cli/cli_doc.rst @@ -8,6 +8,25 @@ AutoControl 可以直接從命令列執行自動化腳本。 與 ``--dry-run``。每次呼叫都有獨立變數範圍,不論是否傳入 ``--var``;種子與動作寫入的 變數在成功或出錯後都會捨棄,包含在同一個 Python 行程中重複呼叫與預演。 +擷取與查閱動作日誌 +================== + +.. code-block:: bash + + python -m je_auto_control.cli run script.json --journal run.jsonl --journal-run-id example + python -m je_auto_control.cli journal run.jsonl --run-id example --limit 100 + +``run --journal PATH`` 附加結構化生命週期快照,保留原有 stdout 紀錄與動作失敗的退出碼, +也支援 ``--dry-run`` 與 ``--var``。未傳入 ``--journal-run-id ID`` 時會產生新的 run ID; +該選項必須搭配 ``--journal``。此操作不會自動建立 history 列或資料庫。 + +``journal PATH`` 驗證整份檔案,再以 JSON 陣列按附加順序輸出遮罩後的快照。 +``--run-id`` 篩選指定執行;``--limit`` 在篩選後限制輸出筆數,允許 1–10,000,預設 100。 +驗證仍會讀取整份檔案。開始與結束快照分開呈現,未完成項目不會被推定成功。 +檔案不存在時回傳空陣列且不建立檔案;已提交的資料損壞時在任何 JSON 輸出前報錯。 +查閱會再次套用日誌遮罩規則,不修改來源檔。兩個入口皆沿用既有的允許根目錄檢查; +預設範圍以外的位置可透過 ``AUTOCONTROL_ALLOWED_PATH_ROOTS`` 設定。 + 執行單一動作檔案 ================ diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index 368d9892f..acbe00165 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -338,7 +338,7 @@ GUI:**OCR Reader** 分頁。可用既有的選取 overlay 圈出區域(留 執行期變數與資料驅動流程控制 ============================ -過去 :mod:`script_vars.interpolate` 只能在執行前一次性把 ``${var}`` +過去 ``script_vars.interpolate`` 只能在執行前一次性把 ``${var}`` 取代成靜態 mapping 中的值,腳本沒辦法在執行時修改狀態。``VariableScope`` 是 executor 暴露給流程控制指令的執行期 mapping,讓它們能讀寫與 runtime interpolator 相同的容器。 @@ -469,7 +469,7 @@ LLM 動作規劃器 # 或者一行做完: ac.run_from_description("開記事本,輸入 hello", executor=executor) -後端選擇對齊 :mod:`vision.backends`: +後端選擇對齊 ``vision.backends``: - Anthropic(``anthropic`` SDK,``ANTHROPIC_API_KEY``)— 預設 - 用 ``AUTOCONTROL_LLM_BACKEND``、``AUTOCONTROL_LLM_MODEL`` 覆寫 @@ -532,7 +532,7 @@ Headless viewer(控制別人):: - ``type`` ``{text}`` - ``ping`` -Action-JSON 指令(使用 :mod:`utils.remote_desktop.registry` 的單例):: +Action-JSON 指令(使用 ``utils.remote_desktop.registry`` 的單例):: AC_start_remote_host # token, bind, port, fps, quality, region AC_stop_remote_host @@ -953,7 +953,7 @@ GUI:*傳送檔案...* 按鈕開啟檔案選擇器 + 目的路徑提示,上 ================================ Viewer 分頁不再把遠端畫面內嵌在面板裡 — viewer 認證成功後,會 -另外開啟一個獨立的 :class:`RemoteScreenWindow` 顯示遠端桌面, +另外開啟一個獨立的 ``RemoteScreenWindow`` 顯示遠端桌面, 面板本身只剩下連線卡片 + 控制元件。關閉 popup 視窗的 ✕ 按鈕 會自動斷線,跟 AnyDesk 的 session 視窗體驗一致。 @@ -1277,7 +1277,7 @@ GUI: **Secrets** 分頁 — 建立 vault、解鎖、新增 / 移除條目、變 Webhook(HTTP push)觸發 ======================= -內建的 :mod:`http.server` dispatcher 在外部服務 POST 到註冊路徑時 +內建的 ``http.server`` dispatcher 在外部服務 POST 到註冊路徑時 觸發腳本。可設定路徑、允許的方法(``GET``、``POST``、``PUT``、``PATCH``、 ``DELETE``;其他方法在註冊時就會被拒絕)、可選 bearer token;請求方法、 路徑、query、headers、原始 body、解析後 JSON 都會種到變數作用域:: @@ -1311,7 +1311,7 @@ Action JSON 指令:: 每次觸發以 ``trigger`` 來源寫入 run history,source id 為 ``webhook:``,讓 dashboard 把 webhook 活動和其他 trigger 並排 顯示;只要有任何動作失敗,這次觸發就記為錯誤(並附錯誤截圖),排程、觸發器與熱鍵的執行也一樣。Body 上限 1 MiB,bearer token 比對用 -:func:`hmac.compare_digest`。除非你真的需要從網路其他地方連入, +``hmac.compare_digest``。除非你真的需要從網路其他地方連入, 否則綁定 ``127.0.0.1``。 GUI: **Webhooks** 分頁 — 啟動 / 停止伺服器、註冊路徑、檢視每條 @@ -1370,16 +1370,18 @@ Watcher 在 process 記憶體中追蹤已觸發的 UID,可選擇把訊息標為 GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher、 手動觸發一次輪詢、檢視最近錯誤與觸發次數。 -結構化動作日誌儲存(待接入執行流程) -==================================== +結構化動作日誌(部分介面待接入) +================================ -B1 的儲存與明確擷取位於 ``je_auto_control.utils.action_journal``;門面與動作指令匯出、 -CLI/MCP/GUI 與手動執行的 history 控制仍待接入。應用程式可明確寫入 schema-v1 快照:: +B1 的儲存與明確擷取/查閱 API 已從 ``je_auto_control`` 匯出,也可透過 +``je_auto_control.utils.action_journal`` 使用。CLI 擷取與有界快照查閱已接入; +動作指令、MCP/GUI 與手動執行的 history 控制仍待接入。B1 整合期間,日誌與來源連結 API +仍屬實驗性介面。應用程式可明確寫入 schema-v1 快照:: import time from dataclasses import replace from pathlib import Path - from je_auto_control.utils.action_journal import ActionEvent, ActionJournal, read_events + from je_auto_control import ActionEvent, ActionJournal, read_events journal = ActionJournal(Path("actions.jsonl")) started = ActionEvent( @@ -1405,7 +1407,7 @@ CLI/MCP/GUI 與手動執行的 history 控制仍待接入。應用程式可 亦可明確擷取 executor 執行:: from pathlib import Path - from je_auto_control.utils.action_journal import execute_action_with_journal, read_events + from je_auto_control import execute_action_with_journal, read_events summary = execute_action_with_journal( [["AC_set_var", {"name": "count", "value": 1}]], diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 3e2c3fafd..b22723897 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -463,3 +463,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: all three new reproductions pass after failing on the old boundary; the related DAG/journal/history/callback/deferred/flow selection passes 265 tests. Three-platform whole-package mypy, ruff and bandit pass; 44 documentation, complexity, budget, exception and import checks pass. Strict English/Chinese Sphinx and wheel/source builds pass; the packaged DAG runner matches the source. Pylint retains legacy findings (9.00/10); radon reports no function above complexity B. - **Files**: `utils/dag/runner.py`, diagnostic-scope regressions, three READMEs, Sphinx vault reference, changelog and architecture maps. Completes the node-error diagnostic boundary missing from U-20261018-13's worker-mask coverage. - **Open items**: B1 operation surfaces/manual history wiring and F3 shared-worker cancellation remain in `Progress.md`; native and paid-API acceptance remain deferred. + +## U-20261019-02 · 2026-10-19 · Expose journal capture and bounded CLI inspection · #done #journal #cli + +- **What**: the Qt-free facade exports the eight existing journal storage/capture/read names and history `JournalLink`. CLI `run --journal PATH` captures in the existing variable scope, with generated or explicit `--journal-run-id`; original stdout records and failure exit codes remain unchanged. +- **Inspection**: `journal PATH` validates the whole file, optionally filters `--run-id`, and prints sanitized snapshots in append order. Output defaults to 100 and is bounded to 1–10,000 after filtering; validation still loads the complete file. Start/completion snapshots remain separate. Missing files return an empty array; corrupt committed input fails before output. Reads never rewrite the source. Both paths use the existing allowed-root guard, and neither implicitly creates a history row/database. +- **Validation**: the full suite passes 11,783 tests with 69 skipped and 87.77% coverage. 11 entry-point reproductions failed before implementation. All 17 new cases now pass, including an isolated process with Qt and SQLite blocked; no implicit state directory is created. The related CLI/storage/capture/history/import selection passes 134 tests, and the updated entry-point/documentation/budget/exception/cross-project selection passes 128. Three-platform whole-package mypy, ruff and scoped bandit pass; radon reports no CLI function above complexity B. Strict English/Chinese Sphinx and isolated wheel/source builds pass; both changed production modules match the wheel. Pylint scores 8.76/10 with intentional lazy imports and existing parser/docstring findings. +- **Documentation**: three READMEs, bilingual CLI/journal references, changelog, API lifecycle and measured architecture maps move together. Journal/provenance APIs are explicitly experimental while B1 remains partial; broad facade exports do not promote them into the versioned stable API. Facade count citations are now measured at 1,273 and guarded against drift. Twelve unresolved legacy Sphinx roles in the touched bilingual guide now use code literals rather than nonexistent cross-reference targets. +- **Open items**: B1 action commands, MCP/GUI, manual-run history ownership and GUI journal browsing remain in `Progress.md`, alongside B2/B3 and the wider modernization plan. Native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index 989b0052f..9f360ae28 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261019-02 | 2026-10-19 | Expose journal capture and bounded CLI inspection | #done #journal #cli | [2026-10](2026-10.md) | | U-20261019-01 | 2026-10-19 | Keep complete DAG node diagnostics inside their owner mask | #incident #security #dag | [2026-10](2026-10.md) | | U-20261018-19 | 2026-10-18 | Preserve interrupted execution through history, workers and recovery guards | #done #executor #cancellation | [2026-10](2026-10.md) | | U-20261018-18 | 2026-10-18 | Link explicit journals to existing run-history owners | #done #journal #history | [2026-10](2026-10.md) | @@ -383,7 +384,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 47 | +| [2026-10.md](2026-10.md) | 2026-10 | 48 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 475c40ca2..751c716c9 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -1155,7 +1155,12 @@ ) # Run history (headless) from je_auto_control.utils.run_history.history_store import ( - HistoryStore, RunRecord, default_history_store, + HistoryStore, JournalLink, RunRecord, default_history_store, +) +# Structured action provenance (headless, explicit capture only) +from je_auto_control.utils.action_journal import ( + ActionEvent, ActionJournal, ActionJournalError, JournalBinding, JournalRunResult, + execute_action_with_journal, journal_scope, read_events, ) # Triggers (headless) from je_auto_control.utils.triggers.trigger_engine import ( @@ -1806,7 +1811,9 @@ def start_autocontrol_gui(*args, **kwargs): "PrometheusExporter", "default_metrics_exporter", "render_metrics_text", "Tracer", "default_tracer", "traced", # Run history - "HistoryStore", "RunRecord", "default_history_store", + "HistoryStore", "JournalLink", "RunRecord", "default_history_store", + "ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunResult", + "execute_action_with_journal", "journal_scope", "read_events", # Accessibility "AccessibilityElement", "AccessibilityNotAvailableError", "accessibility_status", diff --git a/je_auto_control/cli.py b/je_auto_control/cli.py index 01eee1fd2..c9f1bd889 100644 --- a/je_auto_control/cli.py +++ b/je_auto_control/cli.py @@ -6,7 +6,9 @@ Usage:: je_auto_control run script.json [--var x=10 --var y=20] [--dry-run] - [--allow-package NAME ...] + [--allow-package NAME ...] [--journal run.jsonl] + [--journal-run-id ID] + je_auto_control journal run.jsonl [--run-id ID] [--limit 100] je_auto_control validate script.json # alias: lint je_auto_control list-commands [--filter mouse] [--json] je_auto_control fmt script.json [--check] @@ -27,6 +29,7 @@ import sys import threading import time +from contextlib import AbstractContextManager, nullcontext from typing import Callable, Dict, List, Optional, Sequence from je_auto_control.utils.logging.secret_scope import scrub_secret_text, secret_log_scope @@ -76,7 +79,7 @@ def cmd_run(args: argparse.Namespace) -> int: variables = _parse_vars(args.var) _allow_packages(args.allow_package) reset_recorded_failures() - with execution_scope(variables): + with _run_capture(args), execution_scope(variables): # Seeded, not interpolated over the whole tree: that failed on a loop # body's ${item} ("Unknown variable") and would print ${secrets.X} # resolved into the dry-run keys. @@ -92,6 +95,33 @@ def cmd_run(args: argparse.Namespace) -> int: return 0 +def _run_capture(args: argparse.Namespace) -> AbstractContextManager[object]: + """Open only an explicitly requested journal, before any action effects.""" + from pathlib import Path + from je_auto_control.utils.action_journal import ActionJournal, journal_scope + from je_auto_control.utils.path_guard.path_guard import validate_path + path = getattr(args, "journal", None) + run_id = getattr(args, "journal_run_id", None) + if path is None: + if run_id is not None: + raise ValueError("--journal-run-id requires --journal") + return nullcontext() + return journal_scope(ActionJournal(validate_path(Path(path))), run_id=run_id) + + +def cmd_journal(args: argparse.Namespace) -> int: + """Read sanitized snapshots in append order without changing the journal.""" + from pathlib import Path + from je_auto_control.utils.action_journal import read_events + from je_auto_control.utils.action_journal.redaction import sanitize_event + from je_auto_control.utils.path_guard.path_guard import validate_path + events = read_events(validate_path(Path(args.path)), run_id=args.run_id) + payload = [sanitize_event(event) for event in events[:args.limit]] + json.dump(payload, sys.stdout, indent=2, ensure_ascii=False, allow_nan=False) + sys.stdout.write("\n") + return 0 + + def cmd_validate(args: argparse.Namespace) -> int: """Validate an action file's structure without executing it.""" from je_auto_control.utils.executor.action_executor import executor @@ -266,12 +296,21 @@ def build_parser() -> argparse.ArgumentParser: help="name=value override; may be repeated") p_run.add_argument("--dry-run", action="store_true", help="record actions without calling them") + p_run.add_argument("--journal", metavar="PATH", help="append structured action snapshots to this journal") + p_run.add_argument("--journal-run-id", metavar="ID", help="explicit capture run ID; requires --journal") p_run.add_argument("--allow-package", action="append", metavar="NAME", help="package AC_add_package_to_executor may load (submodules " "included); may be repeated. JE_AUTOCONTROL_ALLOWED_PACKAGES " "lists them for every entry point") p_run.set_defaults(func=cmd_run) + p_journal = sub.add_parser("journal", help="Read sanitized action journal snapshots") + p_journal.add_argument("path") + p_journal.add_argument("--run-id", help="Only snapshots for this journal run") + p_journal.add_argument("--limit", type=_journal_limit, default=100, + help="maximum output snapshots, 1-10000 (default: 100)") + p_journal.set_defaults(func=cmd_journal) + for name in ("validate", "lint"): p_validate = sub.add_parser(name, help="Validate an action JSON file") p_validate.add_argument("script") @@ -338,6 +377,17 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _journal_limit(text: str) -> int: + """Bound journal output without silently accepting zero or negative slices.""" + try: + limit = int(text) + except ValueError as error: + raise argparse.ArgumentTypeError("journal limit must be an integer from 1 to 10000") from error + if not 1 <= limit <= 10000: + raise argparse.ArgumentTypeError("journal limit must be an integer from 1 to 10000") + return limit + + def _port(text: str) -> int: """A TCP port for argparse: 0 (any free port) to 65535.""" try: diff --git a/test/unit_test/headless/test_doc_counts.py b/test/unit_test/headless/test_doc_counts.py index 5c950b919..da766a986 100644 --- a/test/unit_test/headless/test_doc_counts.py +++ b/test/unit_test/headless/test_doc_counts.py @@ -24,6 +24,11 @@ def _commands() -> int: return len(executor.known_commands()) +def _facade_names() -> int: + import je_auto_control + return len(je_auto_control.__all__) + + def _mcp_tools() -> int: from je_auto_control.utils.mcp_server.tools import build_default_tool_registry # Pinned, not defaulted: the registry consults @@ -58,6 +63,12 @@ def _typing_exemptions() -> int: # (doc, regex with one capture group, what it counts, how to measure it) CITATIONS: List[Tuple[str, str, str, Callable[[], int]]] = [ + ("architecture_explore.md", r"套件門面 `__all__` 公開名稱數 \| ([\d,]+) \|", + "facade names", _facade_names), + ("architecture_explore.md", r"把 ([\d,]+) 個公開名稱集中再匯出", + "facade names", _facade_names), + ("architecture_explore.md", r"集中匯入並再匯出 ([\d,]+) 個公開名稱", + "facade names", _facade_names), ("architecture_explore.md", r"`AC_\*` 動作指令數(`known_commands\(\)` 實測) \| (\d+) \|", "AC_* commands", _commands), @@ -107,7 +118,7 @@ def test_quoted_count_matches_the_tree(doc, pattern, label, measure): ) actual = measure() for quoted in found: - assert int(quoted) == actual, ( + assert int(quoted.replace(",", "")) == actual, ( f"{doc} says {quoted} {label}, the tree has {actual}. " f"Re-measure and update every document that quotes it " f"(architecture_explore.md, README.md and both translations)." diff --git a/test/unit_test/headless/test_journal_public_cli.py b/test/unit_test/headless/test_journal_public_cli.py new file mode 100644 index 000000000..6a393bda5 --- /dev/null +++ b/test/unit_test/headless/test_journal_public_cli.py @@ -0,0 +1,195 @@ +"""Public journal entry points preserve run, storage and CLI contracts.""" +import asyncio +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +import je_auto_control as ac +from je_auto_control.cli import main +from je_auto_control.utils import action_journal +from je_auto_control.utils.action_journal.capture import current_journal +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.run_history import JournalLink + + +def test_facade_capture_and_filtered_read_keep_original_results(tmp_path, monkeypatch): + seen = [] + monkeypatch.setitem(executor.event_dict, "AC_public_journal_probe", lambda value: seen.append(value) or value) + path = tmp_path / "public.jsonl" + actions = [["AC_public_journal_probe", {"value": "${message}"}]] + result = ac.execute_action_with_journal(actions, path, variables={"message": "hello"}, run_id="public") + assert seen == ["hello"] and result["failed_actions"] == 0 + assert result["record"] and result["run_id"] == "public" + assert ac.read_events(path, run_id="other") == [] + assert [event.status for event in ac.read_events(path, run_id="public")] == ["incomplete", "ok"] + assert actions[0][1]["value"] == "${message}" and current_journal() is None + for name in action_journal.__all__: + assert name in ac.__all__ and getattr(ac, name) is getattr(action_journal, name) + assert ac.JournalLink is JournalLink and "JournalLink" in ac.__all__ + + +def _script(tmp_path, actions): + path = tmp_path / "actions.json" + path.write_text(json.dumps(actions), encoding="utf-8") + return str(path) + + +def test_cli_capture_keeps_stdout_and_variable_execution(tmp_path, monkeypatch, capsys): + seen = [] + monkeypatch.setitem(executor.event_dict, "AC_public_journal_probe", lambda value: seen.append(value) or value) + script = _script(tmp_path, [["AC_public_journal_probe", {"value": "${message}"}]]) + path = tmp_path / "cli.jsonl" + assert main(["run", script, "--var", "message=hello", "--journal", str(path), + "--journal-run-id", "cli"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert "journal_path" not in payload and seen == ["hello"] + events = action_journal.read_events(path) + assert [event.status for event in events] == ["incomplete", "ok"] + assert all(event.run_id == "cli" for event in events) + assert current_journal() is None + + +def test_cli_dry_run_capture_avoids_dispatch(tmp_path, monkeypatch, capsys): + def fail(): + pytest.fail("dry-run dispatched an action") + monkeypatch.setitem(executor.event_dict, "AC_public_journal_probe", fail) + path = tmp_path / "dry.jsonl" + script = _script(tmp_path, [["AC_public_journal_probe"]]) + assert main(["run", script, "--dry-run", "--journal", str(path)]) == 0 + assert any("dry-run" in key for key in json.loads(capsys.readouterr().out)) + assert [event.status for event in action_journal.read_events(path)] == ["incomplete", "dry_run"] + + +def test_cli_failed_action_retains_exit_code_and_journal_status(tmp_path, monkeypatch, capsys): + def fail(): + raise ValueError("controlled action failure") + monkeypatch.setitem(executor.event_dict, "AC_public_journal_probe", fail) + script = _script(tmp_path, [["AC_public_journal_probe"]]) + path = tmp_path / "failed.jsonl" + assert main(["run", script, "--journal", str(path)]) == 1 + assert "1 action(s) failed" in capsys.readouterr().err + assert [event.status for event in action_journal.read_events(path)] == ["incomplete", "error"] + + +def test_cli_foreign_destination_is_preserved_before_effects(tmp_path, monkeypatch, capsys): + seen = [] + monkeypatch.setitem(executor.event_dict, "AC_public_journal_probe", lambda: seen.append(True)) + script = _script(tmp_path, [["AC_public_journal_probe"]]) + path = tmp_path / "foreign.txt" + path.write_bytes(b"leave this file intact\n") + assert main(["run", script, "--journal", str(path)]) == 1 + assert seen == [] and path.read_bytes() == b"leave this file intact\n" + assert "error:" in capsys.readouterr().err and current_journal() is None + + +def test_cli_run_id_requires_capture(tmp_path, capsys): + script = _script(tmp_path, [["AC_screen_size"]]) + assert main(["run", script, "--journal-run-id", "unused"]) == 1 + assert "requires --journal" in capsys.readouterr().err + + +def test_cli_journal_filters_bounds_and_does_not_rewrite(tmp_path, capsys): + path = tmp_path / "read.jsonl" + journal = action_journal.ActionJournal(path) + for sequence in range(4): + journal.append(action_journal.ActionEvent(run_id="one" if sequence < 3 else "two", + step_id=str(sequence), command="AC_write", parameters={"password": "fake-private-text"}, + started_at=1)) + # An external producer may bypass the writer's masking. The CLI applies + # the same policy at presentation without modifying the original file. + external = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + for item in external: + item["parameters"]["password"] = "fake-private-text" + path.write_text("".join(json.dumps(item) + "\n" for item in external), encoding="utf-8") + before = path.read_bytes() + assert main(["journal", str(path), "--run-id", "one", "--limit", "2"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert [item["sequence"] for item in payload] == [1, 2] + assert all(item["run_id"] == "one" and item["status"] == "incomplete" for item in payload) + assert "fake-private-text" not in json.dumps(payload) + assert path.read_bytes() == before + + +def test_cli_capture_preserves_cancellation_and_restores_context(tmp_path, monkeypatch): + signal = asyncio.CancelledError("controlled cancellation") + def cancel(): + raise signal + monkeypatch.setitem(executor.event_dict, "AC_public_journal_probe", cancel) + script = _script(tmp_path, [["AC_public_journal_probe"]]) + path = tmp_path / "cancelled.jsonl" + with pytest.raises(asyncio.CancelledError) as caught: + main(["run", script, "--journal", str(path)]) + assert caught.value is signal and current_journal() is None + assert [event.status for event in action_journal.read_events(path)] == ["incomplete", "cancelled"] + + +@pytest.mark.parametrize("limit", ["0", "-1", "10001", "invalid"]) +def test_cli_journal_rejects_invalid_output_limits(tmp_path, limit): + with pytest.raises(SystemExit) as error: + main(["journal", str(tmp_path / "absent.jsonl"), "--limit", limit]) + assert error.value.code == 2 + + +def test_cli_journal_corruption_never_prints_raw_input(tmp_path, capsys): + path = tmp_path / "bad.jsonl" + path.write_text('{"password":"fake-private-value"}\n', encoding="utf-8") + assert main(["journal", str(path)]) == 1 + captured = capsys.readouterr() + assert captured.out == "" and "fake-private-value" not in captured.err + + +def test_cli_journal_missing_path_is_empty_without_creation(tmp_path, capsys): + path = tmp_path / "absent.jsonl" + assert main(["journal", str(path)]) == 0 + assert json.loads(capsys.readouterr().out) == [] and not path.exists() + + +@pytest.mark.parametrize("command", ["run", "journal"]) +def test_cli_journal_paths_use_allowed_roots(tmp_path, monkeypatch, capsys, command): + from je_auto_control.utils.path_guard import path_guard + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setattr(path_guard, "default_allowed_roots", lambda: [allowed]) + path = tmp_path / "outside.jsonl" + args = ["journal", str(path)] if command == "journal" else [ + "run", _script(tmp_path, [["AC_screen_size"]]), "--journal", str(path)] + assert main(args) == 1 + assert "error:" in capsys.readouterr().err and not path.exists() + + +def test_public_and_cli_journals_work_without_qt_or_sqlite(tmp_path): + probe = ''' +import sys +from pathlib import Path +class Blocker: + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] in {"PySide6", "sqlite3", "_sqlite3"}: + raise ModuleNotFoundError("optional dependency blocked", name=fullname) +sys.meta_path.insert(0, Blocker()) +import je_auto_control as ac +from je_auto_control.cli import main +path = Path(sys.argv[1]) +ac.execute_action_with_journal([["AC_screen_size"]], path, dry_run=True) +if main(["journal", str(path)]) != 0: + raise SystemExit("journal read failed") +if any(name.split(".")[0] in {"PySide6", "sqlite3", "_sqlite3"} for name in sys.modules): + raise SystemExit("optional dependencies were imported") +if (Path.home() / ".je_auto_control").exists(): + raise SystemExit("an implicit state directory was created") +''' + home = tmp_path / "home" + home.mkdir() + env = dict(os.environ, HOME=str(home), USERPROFILE=str(home), + PYTHONPATH=str(Path(__file__).resolve().parents[3])) + env.pop("HOMEDRIVE", None) + env.pop("HOMEPATH", None) + result = subprocess.run( # nosec B603 # nosemgrep # reason: fixed probe with a temporary path as data; no shell + [sys.executable, "-c", probe, str(tmp_path / "headless.jsonl")], + env=env, capture_output=True, text=True, timeout=60, check=False, + ) + assert result.returncode == 0, result.stderr + assert [event["status"] for event in json.loads(result.stdout)] == ["incomplete", "dry_run"] From 84e8d925d7ecfef6cc0ed85a225aa6b415a070d5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 01:22:07 +0800 Subject: [PATCH 020/294] Add scoped journal commands and shared safe inspection --- CHANGELOG.md | 8 + Progress.md | 2 +- README.md | 8 +- README/README_zh-CN.md | 8 +- README/README_zh-TW.md | 8 +- architecture.md | 9 + architecture_explore.md | 48 ++-- docs/API_LIFECYCLE.md | 8 +- docs/CAPABILITY_MATRIX.md | 6 + .../Eng/doc/new_features/new_features_doc.rst | 48 +++- .../Zh/doc/new_features/new_features_doc.rst | 38 ++- docs/updates/2026-10.md | 9 + docs/updates/README.md | 3 +- examples/28_action_journal.py | 29 +++ examples/README.md | 8 + je_auto_control/__init__.py | 4 +- je_auto_control/actions.pyi | 14 ++ je_auto_control/cli.py | 8 +- .../gui/script_builder/command_schema.py | 19 ++ .../utils/action_journal/__init__.py | 3 +- .../utils/action_journal/capture.py | 6 + .../utils/action_journal/commands.py | 22 ++ .../utils/action_journal/inspection.py | 24 ++ je_auto_control/utils/action_lint/linter.py | 2 +- .../utils/executor/action_executor.py | 4 + .../utils/executor/action_schema.py | 1 + je_auto_control/utils/rbac/policy.py | 2 + je_auto_control/utils/stubs/generator.py | 1 + .../headless/test_journal_builder_gui.py | 55 +++++ .../headless/test_journal_commands.py | 221 ++++++++++++++++++ 30 files changed, 574 insertions(+), 52 deletions(-) create mode 100644 examples/28_action_journal.py create mode 100644 je_auto_control/utils/action_journal/commands.py create mode 100644 je_auto_control/utils/action_journal/inspection.py create mode 100644 test/unit_test/headless/test_journal_builder_gui.py create mode 100644 test/unit_test/headless/test_journal_commands.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f648ad5e..71816a86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- `AC_journal_capture` captures a nested body while retaining its owning + executor, variables, strictness and control signals; structural validation + and linting include the body. `AC_journal_read` and public `inspect_journal` + share bounded, sanitized inspection with the CLI. Both commands have Script + Builder forms and require `drive_input` under RBAC, including private reads. + Capture rejects identifiers and destinations containing secrets already known + to the owning run before history linking, file creation or action effects. + - Structured journal types and capture/read helpers, plus history `JournalLink`, are exported from `je_auto_control`. CLI `run --journal PATH` captures actions with an optional `--journal-run-id`; `journal PATH` supports run filtering and diff --git a/Progress.md b/Progress.md index be80a38a6..a53ebde6a 100644 --- a/Progress.md +++ b/Progress.md @@ -14,7 +14,7 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的動作指令/MCP/GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的 MCP 工具/專用 GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 diff --git a/README.md b/README.md index a2000fcd2..dd30f777b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ from JSON files / CLI / servers, and a **GUI tab**. Nothing is GUI-only. - **One API, seven platforms.** `wrapper/platform_wrapper.py` picks the backend at import time; your script does not change between Windows, macOS, X11, and Wayland. -- **Scriptable without Python.** 779 `AC_*` commands cover the whole feature set, so a +- **Scriptable without Python.** 781 `AC_*` commands cover the whole feature set, so a JSON file can do anything the library can — including loops, branches, try/catch, macros, and variables. - **Headless by default.** `import je_auto_control` never loads Qt. The GUI is an @@ -233,7 +233,7 @@ desktop app; tab commands live in the window's **Actions** menu. | Natural-language planner | `plan_actions`, `run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`, `run_agent` | `AC_run_agent` | Computer Use | | Record & replay | `record`, `stop_record` | `AC_record`, `AC_stop_record` | Record | -| JSON scripting | `execute_action`, `execute_files` | all 779 commands | Script, Script Builder | +| JSON scripting | `execute_action`, `execute_files` | all 781 commands | Script, Script Builder | | Variables & flow control | `execute_action_with_vars` | `AC_set_var`, `AC_loop`, `AC_for_each`, `AC_try`, `AC_retry` | Variables | | Data-driven runs | — | `AC_for_each_row` (CSV / JSON / SQLite / Excel) | Data Sources | | Assertions | `assert_text`, `assert_image` | `AC_assert_text` + 20 more | Assertions | @@ -258,7 +258,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. --- -Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). Command, MCP/GUI and manual-run history controls remain pending integration. +Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). `AC_journal_capture` accepts named parameters and runs a nested `body` on the owning executor without replacing its variables; `AC_journal_read` shares sanitized, bounded inspection with the CLI and public `inspect_journal`. Both commands are editable in Script Builder. Capture rejects identifiers or destinations containing secrets known to the run before history linking, file creation or action effects. MCP tools, dedicated GUI browsing and manual-run history controls remain pending integration. Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. DAG calls retain the owner mask through complete node diagnostics, including standalone calls and custom local runners. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. Interrupted history runs are recorded as `error`, while journal steps remain `cancelled`; no error screenshot is taken. Cancellation propagates through non-strict execution, recovery/retry, callbacks and local workers. Hard exits retain their original signal even if history completion fails. @@ -451,7 +451,7 @@ ignore synthetic input, and fall back silently when the driver is absent. | Resource | What's in it | |---|---| -| [`examples/`](examples/) | 27 self-contained scripts: screenshot + click, OCR, scheduler, remote desktop, agent loop, observability, recording, variables, hotkeys, triggers, reports, MCP, REST, secrets, plugins, computer use, Wayland, cross-host DAGs, chat-ops, pytest/BDD, anchor locators. | +| [`examples/`](examples/) | 28 self-contained scripts: screenshot + click, OCR, scheduler, remote desktop, agent loop, observability, recording, variables, hotkeys, triggers, reports, MCP, REST, secrets, plugins, computer use, Wayland, cross-host DAGs, chat-ops, pytest/BDD, anchor locators, action journals. | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | Full API reference, English and 中文. | | [architecture_explore.md](architecture_explore.md) | Every module's responsibility, layer by layer. | | [docs/CAPABILITY_MATRIX.md](docs/CAPABILITY_MATRIX.md) | Capability × platform matrix. | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 266b4bbb7..2c4056a5a 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -19,7 +19,7 @@ - **一套 API,七个平台。** `wrapper/platform_wrapper.py` 在导入时挑选后端;同一份脚本在 Windows、macOS、X11 与 Wayland 上都不需要改写。 -- **不写 Python 也能脚本化。** 779 个 `AC_*` 命令覆盖全部功能,因此一个 JSON 文件能做到库 +- **不写 Python 也能脚本化。** 781 个 `AC_*` 命令覆盖全部功能,因此一个 JSON 文件能做到库 能做的任何事——包含循环、分支、try/catch、宏与变量。 - **默认无头运行。** `import je_auto_control` 绝不会加载 Qt。GUI 是可选包,包在同一个无头内核之外。 - **四种定位方式。** 模板匹配、OCR、无障碍树、视觉语言模型——可通过锚点定位器与自愈回退串接组合。 @@ -221,7 +221,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 自然语言规划 | `plan_actions`、`run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`、`run_agent` | `AC_run_agent` | Computer Use | | 录制与回放 | `record`、`stop_record` | `AC_record`、`AC_stop_record` | Record | -| JSON 脚本 | `execute_action`、`execute_files` | 全部 779 个命令 | Script、Script Builder | +| JSON 脚本 | `execute_action`、`execute_files` | 全部 781 个命令 | Script、Script Builder | | 变量与流程控制 | `execute_action_with_vars` | `AC_set_var`、`AC_loop`、`AC_for_each`、`AC_try`、`AC_retry` | Variables | | 数据驱动执行 | — | `AC_for_each_row`(CSV/JSON/SQLite/Excel) | Data Sources | | 断言 | `assert_text`、`assert_image` | `AC_assert_text` 等 21 个 | Assertions | @@ -245,7 +245,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。动作命令、MCP/GUI 与手动执行的 history 控制仍待接入。 +实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。`AC_journal_capture` 接受命名参数,在拥有者 executor 上执行嵌套 `body`,保留原变量作用域;`AC_journal_read` 与 CLI、公开的 `inspect_journal` 共用遮罩后的有界查询。两个命令都可在 Script Builder 编辑。捕获会在 history 链接、创建文件及执行动作前拒绝含有当次执行已知密钥值的标识符或目标路径。MCP 工具、专用 GUI 查询与手动执行的 history 控制仍待接入。 `${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。DAG 会将拥有者的遮罩保留到 node 诊断完成,包括独立调用与自定义 local runner。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。中止的 history 记为 `error`,journal 步骤保持 `cancelled`,不再获取错误截图。取消会穿过非严格执行、恢复/重试、回调与本机 worker;history 结算失败也不会取代原始硬中止信号。 @@ -413,7 +413,7 @@ Windows、macOS(pyobjc)与 X11(含 XWayland);纯 Wayland 会话的协 | 资源 | 内容 | |---|---| -| [`examples/`](../examples/) | 27 个自包含脚本:截图点击、OCR、调度器、远程桌面、agent loop、可观测性、录制、变量、热键、触发器、报表、MCP、REST、密钥、插件、computer use、Wayland、跨主机 DAG、chat-ops、pytest/BDD、锚点定位。 | +| [`examples/`](../examples/) | 28 个自包含脚本:截图点击、OCR、调度器、远程桌面、agent loop、可观测性、录制、变量、热键、触发器、报表、MCP、REST、密钥、插件、computer use、Wayland、跨主机 DAG、chat-ops、pytest/BDD、锚点定位、动作日志。 | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | 完整 API 参考,含英文与中文。 | | [architecture_explore.md](../architecture_explore.md) | 逐层记录每个模块的职责。 | | [docs/CAPABILITY_MATRIX.md](../docs/CAPABILITY_MATRIX.md) | 能力 × 平台对照矩阵。 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index cc37f273d..b2d9882fc 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -18,7 +18,7 @@ - **一套 API,七個平台。** `wrapper/platform_wrapper.py` 在匯入時挑選後端;同一份腳本在 Windows、macOS、X11 與 Wayland 上都不需要改寫。 -- **不寫 Python 也能腳本化。** 779 個 `AC_*` 指令涵蓋全部功能,因此一個 JSON 檔能做到函式庫 +- **不寫 Python 也能腳本化。** 781 個 `AC_*` 指令涵蓋全部功能,因此一個 JSON 檔能做到函式庫 能做的任何事——包含迴圈、分支、try/catch、巨集與變數。 - **預設無頭執行。** `import je_auto_control` 絕不會載入 Qt。GUI 是選用套件,包在同一個無頭核心之外。 - **四種定位方式。** 樣板比對、OCR、無障礙樹、視覺語言模型——可透過錨點定位器與自癒後備串接組合。 @@ -220,7 +220,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 自然語言規劃 | `plan_actions`、`run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`、`run_agent` | `AC_run_agent` | Computer Use | | 錄製與重播 | `record`、`stop_record` | `AC_record`、`AC_stop_record` | Record | -| JSON 腳本 | `execute_action`、`execute_files` | 全部 779 個指令 | Script、Script Builder | +| JSON 腳本 | `execute_action`、`execute_files` | 全部 781 個指令 | Script、Script Builder | | 變數與流程控制 | `execute_action_with_vars` | `AC_set_var`、`AC_loop`、`AC_for_each`、`AC_try`、`AC_retry` | Variables | | 資料驅動執行 | — | `AC_for_each_row`(CSV/JSON/SQLite/Excel) | Data Sources | | 斷言 | `assert_text`、`assert_image` | `AC_assert_text` 等 21 個 | Assertions | @@ -244,7 +244,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。動作指令、MCP/GUI 與手動執行的 history 控制仍待接入。 +實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。`AC_journal_capture` 接受具名參數,在擁有者 executor 上執行巢狀 `body`,保留原變數範圍;`AC_journal_read` 與 CLI、公開的 `inspect_journal` 共用遮罩後的有界查閱。兩個指令皆可在 Script Builder 編輯。擷取會在 history 連結、開檔及動作執行前拒絕含有當次執行已知秘密的識別碼或目的地。MCP 工具、專用 GUI 查閱與手動執行的 history 控制仍待接入。 `${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。DAG 會將擁有者的遮罩保留到 node 診斷完成,包含獨立呼叫與自訂 local runner。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。中止的 history 記為 `error`,journal 步驟維持 `cancelled`,不再擷取錯誤截圖。取消會穿過非嚴格執行、復原/重試、回呼與本機 worker;history 結算失敗也不會取代原始硬中止訊號。 @@ -413,7 +413,7 @@ Windows、macOS(pyobjc)與 X11(含 XWayland);純 Wayland session 的 | 資源 | 內容 | |---|---| -| [`examples/`](../examples/) | 27 個自足腳本:截圖點擊、OCR、排程器、遠端桌面、agent loop、可觀測性、錄製、變數、熱鍵、觸發器、報表、MCP、REST、機密、外掛、computer use、Wayland、跨主機 DAG、chat-ops、pytest/BDD、錨點定位。 | +| [`examples/`](../examples/) | 28 個自足腳本:截圖點擊、OCR、排程器、遠端桌面、agent loop、可觀測性、錄製、變數、熱鍵、觸發器、報表、MCP、REST、機密、外掛、computer use、Wayland、跨主機 DAG、chat-ops、pytest/BDD、錨點定位、動作日誌。 | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | 完整 API 參考,含英文與中文。 | | [architecture_explore.md](../architecture_explore.md) | 逐層記錄每個模組的職責。 | | [docs/CAPABILITY_MATRIX.md](../docs/CAPABILITY_MATRIX.md) | 能力 × 平台對照矩陣。 | diff --git a/architecture.md b/architecture.md index fd5a7b961..e93f389dc 100644 --- a/architecture.md +++ b/architecture.md @@ -132,6 +132,15 @@ selection. Both new CLI paths use the existing allowed-root guard and create no history row or database implicitly. Capture starts are durable before effects; failed actions, handled loop signals, cancellation and dry-runs receive distinct completion statuses. +`AC_journal_capture` uses `action_journal/commands.py` to validate its nested +body and run it on the current executor, retaining variables, strictness and +loop/cancellation signals. The command opens a new capture root and restores any +outer journal. `inspection.py::inspect_journal` shares default-root confinement, +full-file validation and bounded sanitized snapshots across the facade, CLI and +`AC_journal_read`. Both commands have Script Builder forms and require +`drive_input` under RBAC. Linting walks body-bearing ordinary commands as well +as blocks. A plain outer dry-run previews the capture command without opening +a journal; Python/CLI run-level capture can record individual dry-run steps. `utils/run_history/context.py` binds an existing history row to execution. Scheduler/trigger/hotkey/webhook/email owners bind their own row; local parallel and DAG workers carry it explicitly. `journal_scope` attaches an idempotent diff --git a/architecture_explore.md b/architecture_explore.md index 4c84d23d6..5aa239095 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,15 +19,15 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,087 | -| 程式碼總行數 | 163,110 | +| Python 模組總數(含周邊子專案) | 1,089 | +| 程式碼總行數 | 163,186 | | `je_auto_control/utils/` 子套件數 | 311 | -| `AC_*` 動作指令數(`known_commands()` 實測) | 779 | -| 套件門面 `__all__` 公開名稱數 | 1,273 | +| `AC_*` 動作指令數(`known_commands()` 實測) | 781 | +| 套件門面 `__all__` 公開名稱數 | 1,274 | | GUI 分頁數(`main_widget` 註冊) | 48 | | MCP 工具數(`build_default_tool_registry()` 實測) | 680 | -| `test_*.py` 測試檔/測試函式 | 761 / 8,016 | -| 範例腳本 | 27 | +| `test_*.py` 測試檔/測試函式 | 763 / 8,033 | +| 範例腳本 | 28 | **技術基線**:Python ≥ 3.10、MIT 授權、必要相依只有 `je_open_cv`/`opencv-python`/`pillow`/`mss`/ `defusedxml`/`cryptography`(加上各平台專屬的 pyobjc、python-Xlib);其餘全部是選用 extras @@ -92,7 +92,7 @@ USB/IP 協定、Prometheus 指標),以維持這條輕相依基線。 | 模式 | 落點 | 說明 | | --- | --- | --- | | **Strategy** | `wrapper/platform_wrapper.py` | 依 `sys.platform` 只匯入當前 OS 的 `keyboard`/`mouse`/`screen`/`recorder` 實作;Linux 再細分 Wayland/X11,Wayland 後端不可用時自動退回 XWayland 並記警告。新增平台不需要改 wrapper。 | -| **Facade** | `je_auto_control/__init__.py` | 把 1,273 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | +| **Facade** | `je_auto_control/__init__.py` | 把 1,274 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | | **Command** | `utils/executor/action_executor.py` | `Executor.event_dict` 是字串 → callable 的分派表;JSON 動作檔即指令序列,因此可錄製、序列化、重播、簽章。 | | **Observer** | `utils/callback/`、`utils/observer/`、`utils/triggers/` | 動作完成後觸發回呼;畫面出現/消失/變化與外部事件(webhook/IMAP/檔案)驅動腳本。 | | **Template Method** | `utils/generate_report/` | HTML/JSON/XML 三個產生器共用「收集紀錄 → 格式化 → 寫檔」骨架,各自實作渲染。 | @@ -154,9 +154,9 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `je_auto_control/__init__.py` | 2,001 | **套件門面**。集中匯入並再匯出 1,273 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | +| `je_auto_control/__init__.py` | 2,001 | **套件門面**。集中匯入並再匯出 1,274 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | | `je_auto_control/__main__.py` | 106 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | -| `je_auto_control/cli.py` | 420 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`/`--journal`)、`journal`(依 run 篩選與有界遮罩快照)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | +| `je_auto_control/cli.py` | 416 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`/`--journal`)、`journal`(依 run 篩選與有界遮罩快照)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | | `je_auto_control/api/core.py` | 19 | **穩定無頭 API 門面**:只暴露 `execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、`create_failure_bundle`、`failure_bundle_on_error`、`FailureBundleOptions`。mypy 型別契約以此為起點,現已擴到整包(見「設定基線」)。 | | `je_auto_control/utils/cli_output.py` | 44 | 本套件命令列工具與 stdio 伺服器的標準串流:不論碼頁一律 UTF-8。 | @@ -272,19 +272,19 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,884 行。 +> 25 個套件、約 15,943 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `utils/action_lint/` | 429 | action 檔 linter 與 JSON Schema 產生器(CI 用 `python -m` 進入點) | +| `utils/action_lint/` | 429 | action 檔 linter 與 JSON Schema 產生器;block 與一般指令共用巢狀 body 走訪(CI 用 `python -m` 進入點) | | `utils/action_signing/` | 716 | action 檔 HMAC-SHA256 簽章與 Fernet 加密,`execute_files` 會強制驗簽 | | `utils/checkpoint/` | 129 | 流程檢查點與續跑,讓長 action list 具持久性 | | `utils/codegen/` | 294 | 由 action list 產生可執行的 pytest / python / robot 測試碼 | | `utils/dag/` | 584 | 跨主機 DAG 編排器;node 的完整錯誤處理綁定擁有者秘密遮罩,含獨立呼叫與自訂 local runner | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | -| `utils/action_journal/` | 587 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;門面與 CLI 擷取/有界查閱已接入,其他操作介面待補。 | -| `utils/executor/` | 9,700 | **核心**。`Executor` 指令分派表(779 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/action_journal/` | 640 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;門面、AC 指令、Script Builder 與 CLI 擷取/有界查閱已接入,MCP/專用 GUI/手動 history 待補。 | +| `utils/executor/` | 9,705 | **核心**。`Executor` 指令分派表(781 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -298,7 +298,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/script_vars/` | 291 | 執行期變數作用域與 `${var}` / `${secrets.*}` 插值 | | `utils/skill_library/` | 145 | 具名可重用 action 序列(skill)的持久化倉庫 | | `utils/state_machine/` | 277 | 宣告式有限狀態機驅動 action JSON;預設動作跨狀態共享單次執行範圍 | -| `utils/stubs/` | 311 | 為 `AC_*` 指令面產生型別 stub | +| `utils/stubs/` | 312 | 為 `AC_*` 指令面產生型別 stub | | `utils/test_record/` | 72 | 全域測試紀錄單例,記錄每個動作的參數與例外 | | `utils/work_queue/` | 280 | 交易式工作佇列(dispatcher/performer),支撐大量批次執行 | @@ -631,7 +631,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.14 安全、機密與合規 -> 13 個套件、約 3,523 行。 +> 13 個套件、約 3,525 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -640,7 +640,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/governance/` | 242 | 治理:maker-checker 核准閘門與即時憑證租約 | | `utils/license_policy/` | 240 | 以 SBOM 元件評估 SPDX 授權允許/拒絕政策 | | `utils/provenance/` | 126 | SLSA 建置來源證明(in-toto v1) | -| `utils/rbac/` | 790 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | +| `utils/rbac/` | 792 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | | `utils/redaction/` | 508 | 截圖遮蔽層:規則偵測 + 政策 + 協調器(上傳 VLM 前先遮) | | `utils/sbom/` | 170 | SBOM(CycloneDX)產生 | | `utils/secret_ref/` | 238 | URI scheme 形式的值參照解析 | @@ -697,14 +697,14 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,700 行)— 執行核心 +#### `utils/executor/`(9,705 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,447 | `Executor` 類別與 `event_dict` 分派表(779 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `action_executor.py` | 8,451 | `Executor` 類別與 `event_dict` 分派表(781 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | | `flow_control.py` | 678 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | -| `action_schema.py` | 159 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | +| `action_schema.py` | 160 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | | `action_redaction.py` | 86 | 記錄與紀錄鍵用的遮蔽:`AC_secret_*` 的參數(金庫通行碼、機密值)在寫進 log、當成結果紀錄的鍵之前換成 `***`,巢狀在區塊指令裡的也一樣。 | | `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 | | `flags.py` | 19 | 旗標讀取 `as_bool`:依拼法(`"true"`/`"yes"`/`"on"`/`"1"`)而非 Python 真值判斷,所以 JSON、CLI、MCP 傳來的 `"false"` 是關;所有 adapter 與流程指令共用。 | @@ -1082,12 +1082,12 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 100 | 28,788 | +| `gui/` | 100 | 28,807 | | `utils/mcp_server/` | 37 | 19,167 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,700 | +| `utils/executor/` | 8 | 9,705 | | `utils/usb/` | 17 | 4,816 | -| `je_auto_control/`(頂層 3 檔) | 3 | 2,527 | +| `je_auto_control/`(頂層 3 檔) | 3 | 2,523 | | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | @@ -1102,6 +1102,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 697 | 59,679 | -| **總計** | **1,081** | **163,045** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 699 | 59,735 | +| **總計** | **1,083** | **163,121** | diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index 23ad6675c..a2217a35b 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -12,8 +12,14 @@ links are experimental while B1 interface integration remains in progress. The documented names are available through `je_auto_control` and `je_auto_control.utils.action_journal`; `JournalLink` is also exported from `je_auto_control.utils.run_history`. CLI journal capture/inspection uses the same -experimental schema-v1 snapshots. These additions do not change the lifecycle +experimental schema-v1 snapshots. `inspect_journal` is the sanitized bounded +reader shared by CLI and `AC_journal_read`; `AC_journal_capture` retains the +calling executor's scope, while `execute_action_with_journal` starts a fresh +variable scope. These additions do not change the lifecycle of existing executor or history APIs. +Explicit capture scopes reject identifiers and destinations containing values +known to the enclosing run's resolved-secret mask before linking history or +running actions. - Stable API removal requires a deprecation warning and two minor releases. - Beta API removal requires one release note and one minor release. diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index d19029a09..09a79a66b 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -8,6 +8,7 @@ without a compatibility window. |---|---|---:|---:|---:|---:| | Mouse, keyboard, screenshot | stable | CI | CI/Xvfb + xev | CI/sway + libeis | implementation | | JSON executor and variables | stable | CI | CI | CI | platform-neutral | +| Structured action journals | experimental | controlled tests | controlled tests | platform-neutral | platform-neutral | | Image and anchor locators | beta | CI | CI | implementation | implementation | | Accessibility locator | beta | CI | CI/AT-SPI | CI/AT-SPI | CI (tree read) | | Window management | beta | CI | CI/openbox | unavailable | CI (listing) | @@ -23,6 +24,11 @@ without a compatibility window. real OS runner for it. It must not be interpreted as a production guarantee. Hardware-backed results and known limitations should be attached to releases. +Journal storage, executor capture, provenance links, facade/CLI entry points +and Script Builder commands have controlled tests. The journal work does not +claim native device acceptance. Dedicated browsing, manual history ownership +and MCP journal tools remain pending B1 integration. + Linux Wayland is split: **capture is exercised by CI against a real compositor; input is exercised by CI against a real EI peer and a real portal.** diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index d66a050ad..1a2e35578 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1479,8 +1479,8 @@ Structured action journals (partial integration) B1 exports storage and explicit capture/read APIs from ``je_auto_control`` and ``je_auto_control.utils.action_journal``. CLI capture and bounded snapshot reads -are available; command, MCP/GUI and manual-run history controls remain pending -integration. The journal/provenance APIs remain experimental during B1. +are available, along with action commands and Script Builder forms. MCP tools, +dedicated GUI browsing and manual-run history controls remain pending integration. The journal/provenance APIs remain experimental during B1. Applications can explicitly write schema-v1 snapshots:: import time @@ -1537,6 +1537,50 @@ mid-command retains its durable incomplete start. Completion-storage failure preserves an original cancellation/error and leaves the start incomplete. +Action commands and Script Builder +---------------------------------- + +``AC_journal_capture`` requires named parameters (a JSON object) and executes +its nested ``body`` on the current executor. Positional bodies are rejected +to avoid eagerly interpolating nested data twice. +It keeps the caller's variables, strictness and loop/cancellation signals. +The body is structurally validated and linted, and is interpolated only when +its own steps run. A new capture starts a new journal root and restores any +outer capture on exit; an existing history owner receives the provenance link:: + + ["AC_journal_capture", { + "path": "run.jsonl", "run_id": "example", + "body": [ + ["AC_set_var", {"name": "message", "value": "hello"}], + ["AC_get_var", {"name": "message"}] + ] + }] + +Optional ``device_id`` and ``session_id`` label the capture. Identifiers and +the destination must not contain values known to the enclosing +run's resolved-secret mask; capture rejects them before history linking, file +creation or body execution. The summary has ``run_id``, ``journal_path``, the original ``record`` and +``failed_actions``. This command retains the caller's scope; the Python +``execute_action_with_journal`` convenience function starts fresh variables. +An outer executor dry-run previews the capture command without running its +body or opening a journal. Use Python/CLI run-level capture with ``dry_run`` +to capture individual previewed steps. + +``AC_journal_read`` accepts ``path``, optional ``run_id`` and ``limit`` (default +100, range 1–10,000). It uses public ``inspect_journal`` to validate the whole +file and then return a bounded list of sanitized lifecycle snapshots in append +order. Filtering precedes the output limit; reads preserve the source and never +infer completion. The CLI uses the same reader. These operation paths use the +default allowed-root guard; configure ``AUTOCONTROL_ALLOWED_PATH_ROOTS`` for +additional destinations. Both commands require ``drive_input`` under RBAC; +private journal reads are not screen observation. + +In Script Builder, add **Capture Action Journal** (Flow) to edit metadata and +nested body steps, or **Read Action Journal** (Data) to edit path, run filter +and limit. ``examples/28_action_journal.py`` demonstrates variable-only capture +and inspection without desktop input. + + History provenance links ------------------------ diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index acbe00165..cceda8fdb 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1375,7 +1375,8 @@ GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher B1 的儲存與明確擷取/查閱 API 已從 ``je_auto_control`` 匯出,也可透過 ``je_auto_control.utils.action_journal`` 使用。CLI 擷取與有界快照查閱已接入; -動作指令、MCP/GUI 與手動執行的 history 控制仍待接入。B1 整合期間,日誌與來源連結 API +動作指令與 Script Builder 表單也已接入;MCP 工具、專用 GUI 查閱與手動執行的 history 控制 +仍待接入。B1 整合期間,日誌與來源連結 API 仍屬實驗性介面。應用程式可明確寫入 schema-v1 快照:: import time @@ -1424,6 +1425,41 @@ B1 的儲存與明確擷取/查閱 API 已從 ``je_auto_control`` 匯出,也 完成紀錄無法寫入時會保留原本的取消/錯誤,起始紀錄維持未完成。 +動作指令與 Script Builder +------------------------- + +``AC_journal_capture`` 要求具名參數(JSON 物件),在目前的 executor 上執行巢狀 +``body``;拒絕位置參數形式的 body,避免過早對巢狀資料插值兩次。它保留呼叫端的變數、 +嚴格模式與 loop/取消訊號。body 會接受結構驗證與 lint,並在各步驟執行時才插值。 +新擷取會建立新的 journal 根,離開時恢復外層擷取;已有 history 擁有者時會自動連結來源:: + + ["AC_journal_capture", { + "path": "run.jsonl", "run_id": "example", + "body": [ + ["AC_set_var", {"name": "message", "value": "hello"}], + ["AC_get_var", {"name": "message"}] + ] + }] + +可選的 ``device_id`` 與 ``session_id`` 標記當次擷取。 +識別碼與目的地不得包含外層執行已知的解析後秘密;擷取入口會在 history 連結、 +開檔及 body 執行前拒絕這類值。回傳摘要包含 ``run_id``、 +``journal_path``、原始 ``record`` 與 ``failed_actions``。此指令保留呼叫端的變數範圍; +Python 的 ``execute_action_with_journal`` 便利函式則建立全新變數。 +外層 executor 的 dry-run 只會預演擷取指令,不執行 body、不開啟 journal;若要擷取每個 +預演步驟,請使用 Python/CLI 的執行層級擷取並指定 ``dry_run``。 + +``AC_journal_read`` 接受 ``path``、可選的 ``run_id`` 與 ``limit``(預設 100,範圍 +1–10,000)。它與 CLI 共用公開的 ``inspect_journal``:驗證整份檔案,再按附加順序回傳 +有界的遮罩後生命週期快照清單。先篩選 run,再限制輸出筆數;不修改來源、不推定完成。 +這些操作入口沿用預設允許根目錄檢查;其他位置可透過 ``AUTOCONTROL_ALLOWED_PATH_ROOTS`` +加入。兩個指令在 RBAC 下都要求 ``drive_input``;私人日誌查閱不屬於螢幕觀察權限。 + +在 Script Builder 的 Flow 類別加入 **Capture Action Journal**,可編輯來源標記與巢狀 +body;Data 類別的 **Read Action Journal** 可編輯路徑、run 篩選與輸出上限。 +``examples/28_action_journal.py`` 示範只操作變數的擷取與查閱,不送出桌面輸入。 + + 執行歷史的日誌來源連結 ------------------------ diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index b22723897..38a4dd603 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -471,3 +471,12 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: the full suite passes 11,783 tests with 69 skipped and 87.77% coverage. 11 entry-point reproductions failed before implementation. All 17 new cases now pass, including an isolated process with Qt and SQLite blocked; no implicit state directory is created. The related CLI/storage/capture/history/import selection passes 134 tests, and the updated entry-point/documentation/budget/exception/cross-project selection passes 128. Three-platform whole-package mypy, ruff and scoped bandit pass; radon reports no CLI function above complexity B. Strict English/Chinese Sphinx and isolated wheel/source builds pass; both changed production modules match the wheel. Pylint scores 8.76/10 with intentional lazy imports and existing parser/docstring findings. - **Documentation**: three READMEs, bilingual CLI/journal references, changelog, API lifecycle and measured architecture maps move together. Journal/provenance APIs are explicitly experimental while B1 remains partial; broad facade exports do not promote them into the versioned stable API. Facade count citations are now measured at 1,273 and guarded against drift. Twelve unresolved legacy Sphinx roles in the touched bilingual guide now use code literals rather than nonexistent cross-reference targets. - **Open items**: B1 action commands, MCP/GUI, manual-run history ownership and GUI journal browsing remain in `Progress.md`, alongside B2/B3 and the wider modernization plan. Native and paid-API acceptance remain deferred. + +## U-20261019-03 · 2026-10-19 · Add scoped journal commands and shared safe inspection · #done #journal #executor + +- **What**: `AC_journal_capture` validates and runs a nested body on the owning executor, preserving variables, inherited strictness and loop/cancellation signals. Named parameters are required; a separately reproduced positional-body path that reinterpreted variable data as a vault reference is rejected before nested execution. A new capture has its own root and restores the outer binding. Local parallel/DAG captures retain variables, device/session labels and existing history ownership. Plain outer dry-runs preview the command without creating a journal. +- **Inspection**: public `inspect_journal` shares default-root confinement, full-file validation, output bounds and sanitized snapshots between CLI and `AC_journal_read`. Both commands require `drive_input` under RBAC, including reads of private files. They have Script Builder metadata/read forms; editing a destination preserves child steps. Linting now walks declared nested bodies on ordinary commands as well as blocks. +- **Privacy finding**: four controlled cases reproduced resolved-vault values reaching run IDs, device/session identifiers or destination filenames through runtime argument interpolation. Explicit capture scopes now reject values known to the enclosing run's resolved-secret mask before provenance linking, file creation or body effects. The body remains unexecuted on rejection; original source secret references remain unchanged. +- **Validation**: the full suite passes 11,807 tests with 69 skipped and 87.78% coverage, including all 24 new command/form cases. 12 entry-point cases failed for missing commands/API; four metadata cases and one positional-body case separately reproduced privacy/data-reinterpretation gaps. The final adapter/command/CLI/capture/history selection passes 1,892 tests with six skipped; offscreen form/preservation selection passes 16, and stub/packaging/map checks pass 36. Tests cover owner/worker scopes and history, original strict exceptions, soft failures, loop break, cancellation, dry-run, preflight/lint rejection, roles, output bounds and default-root confinement. Runtime body annotations remain available to the registry introspector. The variable-only example produces four expected snapshots. Three journal modules pass strict mypy and whole-package three-platform typing has zero errors. Ruff and bandit pass; the two new modules score 10.00/10 in pylint and radon reports no journal function above complexity B. Nitpicky English/Chinese Sphinx and isolated wheel/source builds pass; all 12 changed Python modules and the generated stub match both archives, as does the sdist README. Jeffrey_RPA facade/control suites pass 740 tests against this source tree (five existing Discord deprecations). +- **Files and docs**: journal adapters/inspection/capture, executor/schema, RBAC, linter, Script Builder schemas, facade/CLI, regenerated command stubs and their stdlib Path import, headless/offscreen regressions, example 28, three READMEs, API lifecycle/capability matrix, bilingual journal guide, changelog and measured architecture maps (781 commands, 1,274 facade names, 28 examples). +- **Open items**: B1 dedicated MCP tools, GUI journal browsing and manual-run history controls remain in `Progress.md`, along with the existing general MCP nested-path/argument-policy work and the wider modernization plan. Native and paid-API acceptance remain deferred. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9f360ae28..17836453b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261019-03 | 2026-10-19 | Add scoped journal commands and shared safe inspection | #done #journal #executor | [2026-10](2026-10.md) | | U-20261019-02 | 2026-10-19 | Expose journal capture and bounded CLI inspection | #done #journal #cli | [2026-10](2026-10.md) | | U-20261019-01 | 2026-10-19 | Keep complete DAG node diagnostics inside their owner mask | #incident #security #dag | [2026-10](2026-10.md) | | U-20261018-19 | 2026-10-18 | Preserve interrupted execution through history, workers and recovery guards | #done #executor #cancellation | [2026-10](2026-10.md) | @@ -384,7 +385,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 48 | +| [2026-10.md](2026-10.md) | 2026-10 | 49 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/examples/28_action_journal.py b/examples/28_action_journal.py new file mode 100644 index 000000000..a1aea05a3 --- /dev/null +++ b/examples/28_action_journal.py @@ -0,0 +1,29 @@ +"""Capture variable-only actions and inspect snapshots without desktop input. + +Run: python examples/28_action_journal.py run.jsonl --run-id example +""" +import argparse +import json +from pathlib import Path + +from je_auto_control import executor, execution_scope, inspect_journal + + +def main() -> None: + """Append one explicitly owned capture and print its sanitized snapshots.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("journal", type=Path) + parser.add_argument("--run-id", default="example") + args = parser.parse_args() + actions = [["AC_journal_capture", { + "path": str(args.journal), "run_id": args.run_id, + "body": [["AC_set_var", {"name": "message", "value": "hello"}], + ["AC_get_var", {"name": "message"}]], + }]] + with execution_scope(): + executor.execute_action(actions, raise_on_error=True) + print(json.dumps(inspect_journal(args.journal, run_id=args.run_id), indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index 9faa146ce..fe97af1e4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,6 +44,14 @@ glue between AutoControl's public API and your code. ## Running +[`28_action_journal.py`](28_action_journal.py) captures variable-only actions +through `AC_journal_capture` and prints sanitized snapshots with +`inspect_journal`; it sends no desktop input: + +```bash +python examples/28_action_journal.py run.jsonl --run-id example +``` + Each script is standalone and uses only the package facade (`import je_auto_control as ac`). After `pip install -e .` from the repo root: diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 751c716c9..60a67aa14 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -1160,7 +1160,7 @@ # Structured action provenance (headless, explicit capture only) from je_auto_control.utils.action_journal import ( ActionEvent, ActionJournal, ActionJournalError, JournalBinding, JournalRunResult, - execute_action_with_journal, journal_scope, read_events, + execute_action_with_journal, inspect_journal, journal_scope, read_events, ) # Triggers (headless) from je_auto_control.utils.triggers.trigger_engine import ( @@ -1813,7 +1813,7 @@ def start_autocontrol_gui(*args, **kwargs): # Run history "HistoryStore", "JournalLink", "RunRecord", "default_history_store", "ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunResult", - "execute_action_with_journal", "journal_scope", "read_events", + "execute_action_with_journal", "inspect_journal", "journal_scope", "read_events", # Accessibility "AccessibilityElement", "AccessibilityNotAvailableError", "accessibility_status", diff --git a/je_auto_control/actions.pyi b/je_auto_control/actions.pyi index 35e4e39ee..ba6e0d5a7 100644 --- a/je_auto_control/actions.pyi +++ b/je_auto_control/actions.pyi @@ -1,6 +1,7 @@ # AUTOGENERATED by je_auto_control.utils.stubs.generator — do not edit. # Regenerate with: python -m je_auto_control.utils.stubs.generator # ruff: noqa: F401 # generated stub keeps typing imports for forward use +from pathlib import Path from typing import ( Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union, @@ -1554,6 +1555,19 @@ def AC_is_composing() -> Dict[str, Any]: def AC_is_idle(threshold: Any) -> Dict[str, Any]: """Adapter: whether the user has been idle for >= ``threshold`` seconds.""" +def AC_journal_capture( + *, + body: list[object], + path: str, + run_id: str | None = ..., + device_id: str | None = ..., + session_id: str | None = ..., +) -> Any: + """Capture named body arguments on their executor without eager interpolation.""" + +def AC_journal_read(path: str | Path, *, run_id: Optional[str] = ..., limit: int = ...) -> list[dict[str, object]]: + """Validate a whole journal, then return at most ``limit`` sanitized snapshots.""" + def AC_json_extract(data: Any, mapping: Any) -> Dict[str, Any]: """Adapter: extract a {key: path} mapping from data into a flat dict.""" diff --git a/je_auto_control/cli.py b/je_auto_control/cli.py index c9f1bd889..56a1525bd 100644 --- a/je_auto_control/cli.py +++ b/je_auto_control/cli.py @@ -111,12 +111,8 @@ def _run_capture(args: argparse.Namespace) -> AbstractContextManager[object]: def cmd_journal(args: argparse.Namespace) -> int: """Read sanitized snapshots in append order without changing the journal.""" - from pathlib import Path - from je_auto_control.utils.action_journal import read_events - from je_auto_control.utils.action_journal.redaction import sanitize_event - from je_auto_control.utils.path_guard.path_guard import validate_path - events = read_events(validate_path(Path(args.path)), run_id=args.run_id) - payload = [sanitize_event(event) for event in events[:args.limit]] + from je_auto_control.utils.action_journal import inspect_journal + payload = inspect_journal(args.path, run_id=args.run_id, limit=args.limit) json.dump(payload, sys.stdout, indent=2, ensure_ascii=False, allow_nan=False) sys.stdout.write("\n") return 0 diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index a9638555d..8bf02a050 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -70,10 +70,29 @@ def _build_specs() -> List[CommandSpec]: _add_ocr_specs(specs) _add_window_specs(specs) _add_flow_specs(specs) + _add_journal_specs(specs) _add_misc_specs(specs) return specs +def _add_journal_specs(specs: List[CommandSpec]) -> None: + path = FieldSpec("path", FieldType.FILE_PATH) + run_id = FieldSpec("run_id", FieldType.STRING, optional=True) + specs.append(CommandSpec( + "AC_journal_capture", "Flow", "Capture Action Journal", + fields=(path, run_id, FieldSpec("device_id", FieldType.STRING, optional=True), + FieldSpec("session_id", FieldType.STRING, optional=True)), + body_keys=("body",), + description="Capture the nested body on its owning executor; preserve variables and strictness.", + )) + specs.append(CommandSpec( + "AC_journal_read", "Data", "Read Action Journal", + fields=(path, run_id, FieldSpec("limit", FieldType.INT, optional=True, default=100, + min_value=1, max_value=10000)), + description="Return sanitized lifecycle snapshots in append order; validate the whole file.", + )) + + def _add_mouse_specs(specs: List[CommandSpec]) -> None: specs.append(CommandSpec( "AC_click_mouse", "Mouse", "Click Mouse", diff --git a/je_auto_control/utils/action_journal/__init__.py b/je_auto_control/utils/action_journal/__init__.py index 91f969a79..0fde164a1 100644 --- a/je_auto_control/utils/action_journal/__init__.py +++ b/je_auto_control/utils/action_journal/__init__.py @@ -1,9 +1,10 @@ """Versioned storage and explicitly scoped structured-action capture.""" from je_auto_control.utils.action_journal.events import ActionEvent, ActionJournalError from je_auto_control.utils.action_journal.store import ActionJournal, read_events +from je_auto_control.utils.action_journal.inspection import inspect_journal from je_auto_control.utils.action_journal.capture import ( JournalBinding, JournalRunResult, execute_action_with_journal, journal_scope, ) __all__ = ["ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunResult", - "read_events", "execute_action_with_journal", "journal_scope"] + "read_events", "inspect_journal", "execute_action_with_journal", "journal_scope"] diff --git a/je_auto_control/utils/action_journal/capture.py b/je_auto_control/utils/action_journal/capture.py index f656a7ddf..d73860343 100644 --- a/je_auto_control/utils/action_journal/capture.py +++ b/je_auto_control/utils/action_journal/capture.py @@ -13,6 +13,7 @@ from je_auto_control.utils.action_journal.redaction import sanitize_event from je_auto_control.utils.action_journal.store import ActionJournal from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import scrub_secret_text from je_auto_control.utils.path_guard.path_guard import validate_path from je_auto_control.utils.script_vars.execution import execution_scope from je_auto_control.utils.run_history.context import current_history @@ -69,10 +70,15 @@ def journal_scope(journal: ActionJournal, *, run_id: Optional[str] = None, Worker helpers carry the binding explicitly. A new scope starts a new root; nested action bodies share the current binding until their call returns. + Identifiers and destinations containing secrets known to the enclosing run + are refused before history linking, file creation or body execution. """ identity = uuid4().hex if run_id is None else run_id if not isinstance(identity, str) or not identity: raise ActionJournalError("journal run ID must be a non-empty string") + for value in (identity, device_id, session_id, str(journal.path)): + if isinstance(value, str) and scrub_secret_text(value) != value: + raise ActionJournalError("journal identifiers and destination cannot contain a resolved secret") binding = JournalBinding(journal, identity, device_id=device_id, session_id=session_id) history = current_history() if history is not None and not history.store.attach_journal(history.run_id, str(journal.path.resolve()), identity): diff --git a/je_auto_control/utils/action_journal/commands.py b/je_auto_control/utils/action_journal/commands.py new file mode 100644 index 000000000..bec43fd93 --- /dev/null +++ b/je_auto_control/utils/action_journal/commands.py @@ -0,0 +1,22 @@ +"""Executor adapters for explicit nested journal capture.""" +from typing import Optional + +from je_auto_control.utils.action_journal.capture import JournalRunResult, journal_scope +from je_auto_control.utils.action_journal.store import ActionJournal +from je_auto_control.utils.executor.action_schema import validate_actions +from je_auto_control.utils.path_guard.path_guard import validate_path + + +def _capture_body(*, body: list[object], path: str, run_id: Optional[str] = None, + device_id: Optional[str] = None, session_id: Optional[str] = None) -> JournalRunResult: + """Capture named body arguments on their executor without eager interpolation.""" + # pylint: disable-next=import-outside-toplevel # reason: executor registers this adapter + from je_auto_control.utils.executor.action_executor import _running_executor, recorded_failures + runner = _running_executor() + validate_actions(body, runner.known_commands()) + journal = ActionJournal(validate_path(path)) + with journal_scope(journal, run_id=run_id, device_id=device_id, session_id=session_id) as binding: + before = recorded_failures() + record: dict[str, object] = dict(runner.execute_action(body, _validated=True)) + return {"run_id": binding.run_id, "journal_path": str(journal.path), "record": record, + "failed_actions": recorded_failures() - before} diff --git a/je_auto_control/utils/action_journal/inspection.py b/je_auto_control/utils/action_journal/inspection.py new file mode 100644 index 000000000..51ac9c977 --- /dev/null +++ b/je_auto_control/utils/action_journal/inspection.py @@ -0,0 +1,24 @@ +"""Sanitized, bounded journal snapshots shared by operation surfaces.""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from je_auto_control.utils.action_journal.events import ActionJournalError +from je_auto_control.utils.action_journal.redaction import sanitize_event +from je_auto_control.utils.action_journal.store import read_events +from je_auto_control.utils.path_guard.path_guard import validate_path + + +def inspect_journal(path: str | Path, *, run_id: Optional[str] = None, limit: int = 100) -> list[dict[str, object]]: + """Validate a whole journal, then return at most ``limit`` sanitized snapshots. + + Filtering precedes the 1–10,000 output limit; validation still loads the + whole file. Start/completion snapshots stay separate, and missing files + return an empty list. The source is never modified. The default path-root + guard applies, unlike the low-level ``read_events`` storage reader. + """ + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 10000: + raise ActionJournalError("journal limit must be an integer from 1 to 10000") + events = read_events(validate_path(path), run_id=run_id) + return [sanitize_event(event) for event in events[:limit]] diff --git a/je_auto_control/utils/action_lint/linter.py b/je_auto_control/utils/action_lint/linter.py index 92f0131d6..bff26d888 100644 --- a/je_auto_control/utils/action_lint/linter.py +++ b/je_auto_control/utils/action_lint/linter.py @@ -107,7 +107,7 @@ def _lint_item(self, idx: int, item: Any, trail: str = "") -> List[LintIssue]: if isinstance(params, list): return [] # positional arguments, as the executor calls event(*args) return [LintIssue(i.index, i.severity, i.code, trail + i.message) - for i in self._check_required(idx, name, params)] + for i in self._check_required(idx, name, params)] + self._lint_bodies(idx, name, params, trail) @staticmethod def _shape_issue(item: Any) -> Optional[tuple]: diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index be934616c..07d97ec3c 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -34,6 +34,8 @@ ) from je_auto_control.utils.executor.action_redaction import describe_action, redact_actions from je_auto_control.utils.action_journal.capture import action_step +from je_auto_control.utils.action_journal.commands import _capture_body +from je_auto_control.utils.action_journal.inspection import inspect_journal from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS @@ -7493,6 +7495,8 @@ def __init__(self): "AC_check_overflow": _check_overflow, "AC_check_catalog": _check_catalog, "AC_run_resumable": _run_resumable, + "AC_journal_capture": _capture_body, + "AC_journal_read": inspect_journal, "AC_checkpoint_status": _checkpoint_status, "AC_checkpoint_clear": _checkpoint_clear, "AC_mark_screen": _mark_screen, diff --git a/je_auto_control/utils/executor/action_schema.py b/je_auto_control/utils/executor/action_schema.py index 72d0fdf8f..beadc480b 100644 --- a/je_auto_control/utils/executor/action_schema.py +++ b/je_auto_control/utils/executor/action_schema.py @@ -17,6 +17,7 @@ # Keys whose value is a flat action list: [[name, params], ...] FLOW_BODY_KEYS = { + "AC_journal_capture": ("body",), "AC_if_image_found": ("then", "else"), "AC_if_pixel": ("then", "else"), "AC_if_var": ("then", "else"), diff --git a/je_auto_control/utils/rbac/policy.py b/je_auto_control/utils/rbac/policy.py index 7ad5320be..932e48e3f 100644 --- a/je_auto_control/utils/rbac/policy.py +++ b/je_auto_control/utils/rbac/policy.py @@ -72,6 +72,8 @@ #: Commands that need more than ``drive_input``. Every other command is #: covered by the capability of the surface that submitted the action list. COMMAND_CAPABILITIES: Dict[str, str] = { + "AC_journal_capture": _DRIVE, + "AC_journal_read": _DRIVE, "AC_sign_action_file": Capability.SIGN_ACTIONS, "AC_audit_log_list": _AUDIT, "AC_audit_log_verify": _AUDIT, diff --git a/je_auto_control/utils/stubs/generator.py b/je_auto_control/utils/stubs/generator.py index bd2fff4a0..4ab76cdd4 100644 --- a/je_auto_control/utils/stubs/generator.py +++ b/je_auto_control/utils/stubs/generator.py @@ -33,6 +33,7 @@ "# AUTOGENERATED by je_auto_control.utils.stubs.generator — do not edit.\n" "# Regenerate with: python -m je_auto_control.utils.stubs.generator\n" "# ruff: noqa: F401 # generated stub keeps typing imports for forward use\n" + "from pathlib import Path\n" "from typing import (\n" " Any, Callable, Dict, List, Mapping, Optional,\n" " Sequence, Tuple, Union,\n" diff --git a/test/unit_test/headless/test_journal_builder_gui.py b/test/unit_test/headless/test_journal_builder_gui.py new file mode 100644 index 000000000..c10157944 --- /dev/null +++ b/test/unit_test/headless/test_journal_builder_gui.py @@ -0,0 +1,55 @@ +"""Offscreen journal forms retain nested bodies and expose read bounds.""" +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from je_auto_control.gui.script_builder.step_form_view import StepFormView # noqa: E402 +from je_auto_control.gui.script_builder.step_model import action_to_step, step_to_action # noqa: E402 + + +@pytest.fixture +def qapp(): + return QApplication.instance() or QApplication([]) + + +def test_capture_form_keeps_children_when_destination_changes(qapp, tmp_path): + body = [["AC_set_var", {"name": "counter", "value": 1}]] + action = ["AC_journal_capture", {"path": "before.jsonl", "run_id": "example", "body": body}] + step = action_to_step(action) + form = StepFormView() + try: + form.load_step(step) + editor = form._editors["path"] + (editor.property("line_edit") or editor).setText("after.jsonl") + assert step_to_action(step) == ["AC_journal_capture", { + "path": "after.jsonl", "run_id": "example", "body": body}] + assert len(step.bodies["body"]) == 1 + form.resize(640, 280) + form.show() + qapp.processEvents() + assert form.grab().save(str(tmp_path / "journal-capture-form.png")) + finally: + form.deleteLater() + + +def test_read_form_exposes_default_and_limit_bounds(qapp, tmp_path): + step = action_to_step(["AC_journal_read", {"path": "events.jsonl", "run_id": "example"}]) + form = StepFormView() + try: + form.load_step(step) + limit = form._editors["limit"] + assert (limit.validator().bottom(), limit.validator().top(), limit.text()) == (1, 10000, "100") + limit.setText("3") + assert step_to_action(step) == ["AC_journal_read", { + "path": "events.jsonl", "run_id": "example", "limit": 3}] + form.resize(640, 240) + form.show() + qapp.processEvents() + assert form.grab().save(str(tmp_path / "journal-read-form.png")) + finally: + form.deleteLater() diff --git a/test/unit_test/headless/test_journal_commands.py b/test/unit_test/headless/test_journal_commands.py new file mode 100644 index 000000000..674985718 --- /dev/null +++ b/test/unit_test/headless/test_journal_commands.py @@ -0,0 +1,221 @@ +"""Journal commands preserve nested execution and boundary policies.""" +import asyncio +import json + +import pytest + +from je_auto_control.utils.action_journal import ActionJournal, read_events +from je_auto_control.utils.action_journal.capture import current_journal, journal_scope +from je_auto_control.utils.exception.exceptions import AutoControlActionException +from je_auto_control.utils.executor.action_executor import Executor, executor +from je_auto_control.utils.executor.action_schema import validate_actions +from je_auto_control.utils.rbac.authorization import AuthorizationContext, AuthorizationError, authorization_scope +from je_auto_control.utils.rbac.policy import denied_command_in +from je_auto_control.utils.rbac.users import Capability, Role +from je_auto_control.utils.script_vars.execution import execution_scope + + +def _capture(path, body, **metadata): + return ["AC_journal_capture", {"path": str(path), "body": body, **metadata}] + + +def test_capture_defers_body_variables_and_preserves_owner_scope(tmp_path): + path = tmp_path / "nested.jsonl" + body = [["AC_set_var", {"name": "message", "value": "after"}], + ["AC_get_var", {"name": "${variable_name}"}]] + with execution_scope({"message": "before", "variable_name": "message"}): + result = executor.execute_action([_capture(path, body, run_id="nested")], raise_on_error=True) + assert executor.variables.get("message") == "after" + summary = next(iter(result.values())) + assert summary["run_id"] == "nested" and summary["failed_actions"] == 0 + assert "after" in summary["record"].values() + assert body[-1][1]["name"] == "${variable_name}" and current_journal() is None + assert [event.command for event in read_events(path) if event.status == "ok"] == ["AC_set_var", "AC_get_var"] + + +def test_capture_does_not_reinterpolate_variable_data_as_a_secret(tmp_path, monkeypatch): + seen = [] + monkeypatch.setitem(executor.event_dict, "AC_journal_command_probe", lambda value: seen.append(value)) + from je_auto_control.utils.secrets import default_secret_manager + monkeypatch.setattr(default_secret_manager, "get", lambda name: pytest.fail("data was reinterpreted")) + with execution_scope({"message": "${secrets.PRIVATE}"}): + executor.execute_action([_capture(tmp_path / "data.jsonl", [ + ["AC_journal_command_probe", {"value": "${message}"}]])], raise_on_error=True) + assert seen == ["${secrets.PRIVATE}"] + + +def test_capture_uses_private_executor_and_restores_outer_journal(tmp_path): + private = Executor() + private.variables.set("name", "private") + inner, outer = tmp_path / "inner.jsonl", tmp_path / "outer.jsonl" + with journal_scope(ActionJournal(outer), run_id="outer") as owner: + result = private.execute_action([_capture(inner, [["AC_get_var", {"name": "name"}]], run_id="inner")], + raise_on_error=True) + assert current_journal() is owner + assert "private" in next(iter(result.values()))["record"].values() + assert all(event.run_id == "inner" and event.parent_id is None for event in read_events(inner)) + assert all(event.run_id == "outer" for event in read_events(outer)) + + +def test_nested_validation_rejects_unknown_body_before_prior_actions(tmp_path, monkeypatch): + seen = [] + monkeypatch.setitem(executor.event_dict, "AC_journal_command_probe", lambda: seen.append(True)) + actions = [["AC_journal_command_probe"], _capture(tmp_path / "invalid.jsonl", [["AC_unknown_journal_body"]])] + with pytest.raises(AutoControlActionException, match="unknown command"): + executor.execute_action(actions, raise_on_error=True) + assert seen == [] and not (tmp_path / "invalid.jsonl").exists() + + +@pytest.mark.parametrize("strict", [False, True]) +def test_capture_inherits_strictness_and_records_failure(tmp_path, monkeypatch, strict): + failure = ValueError("controlled failure") + def fail(): + raise failure + monkeypatch.setitem(executor.event_dict, "AC_journal_command_probe", fail) + path = tmp_path / "failed.jsonl" + actions = [_capture(path, [["AC_journal_command_probe"]])] + if strict: + with pytest.raises(ValueError) as caught: + executor.execute_action(actions, raise_on_error=True) + assert caught.value is failure + else: + result = executor.execute_action(actions) + assert next(iter(result.values()))["failed_actions"] == 1 + assert [event.status for event in read_events(path)] == ["incomplete", "error"] + assert current_journal() is None + + +def test_capture_preserves_break_and_cancellation_signals(tmp_path, monkeypatch): + path = tmp_path / "break.jsonl" + executor.execute_action([["AC_loop", {"times": 2, "body": [ + _capture(path, [["AC_break"]])]}]], raise_on_error=True) + assert [event.status for event in read_events(path)] == ["incomplete", "ok"] + signal = asyncio.CancelledError("controlled cancellation") + def cancel(): + raise signal + monkeypatch.setitem(executor.event_dict, "AC_journal_command_probe", cancel) + cancelled = tmp_path / "cancelled.jsonl" + with pytest.raises(asyncio.CancelledError) as caught: + executor.execute_action([_capture(cancelled, [["AC_journal_command_probe"]])]) + assert caught.value is signal and current_journal() is None + assert [event.status for event in read_events(cancelled)] == ["incomplete", "cancelled"] + + +def test_capture_is_inert_during_outer_dry_run(tmp_path): + path = tmp_path / "dry.jsonl" + result = executor.execute_action([_capture(path, [["AC_screen_size"]])], dry_run=True) + assert any("dry-run" in key for key in result) and not path.exists() + + +@pytest.mark.parametrize("name", ["AC_journal_capture", "AC_journal_read"]) +def test_viewer_cannot_hide_journal_operations_in_a_payload(name): + viewer = AuthorizationContext("viewer", Role.VIEWER) + action = [name, {"path": "private.jsonl", "body": [["AC_screen_size"]]}] + assert denied_command_in({"nested": [action]}, viewer) == (name, Capability.DRIVE_INPUT) + with authorization_scope(viewer), pytest.raises(AuthorizationError): + executor.execute_action([action], raise_on_error=True) + + +def test_read_command_returns_sanitized_bounded_snapshots(tmp_path): + path = tmp_path / "read.jsonl" + executor.execute_action([_capture(path, [["AC_set_var", {"name": "x", "value": 1}]], run_id="first")], + raise_on_error=True) + before = path.read_bytes() + result = executor.execute_action([["AC_journal_read", {"path": str(path), "run_id": "first", "limit": 1}]], + raise_on_error=True) + payload = next(iter(result.values())) + assert len(payload) == 1 and payload[0]["run_id"] == "first" + assert payload[0]["status"] == "incomplete" and path.read_bytes() == before + + +def test_schema_exposes_capture_body_and_linter_checks_it(tmp_path): + from je_auto_control.gui.script_builder.command_schema import _build_specs + from je_auto_control.utils.action_lint import lint_actions + specs = {spec.command: spec for spec in _build_specs()} + assert specs["AC_journal_capture"].body_keys == ("body",) + assert {field.name for field in specs["AC_journal_read"].fields} == {"path", "run_id", "limit"} + actions = [_capture(tmp_path / "bad.jsonl", [["AC_unknown_journal_body"]])] + issues = lint_actions(actions) + assert any(issue.code == "unknown-command" for issue in issues) + with pytest.raises(AutoControlActionException): + validate_actions(actions, executor.known_commands()) + + +@pytest.mark.parametrize("name", ["AC_journal_capture", "AC_journal_read"]) +def test_journal_commands_enforce_default_path_roots(tmp_path, monkeypatch, name): + from je_auto_control.utils.path_guard import path_guard + from je_auto_control.utils.path_guard import PathNotAllowedError + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setattr(path_guard, "default_allowed_roots", lambda: [allowed]) + path = tmp_path / "outside.jsonl" + params = {"path": str(path)} + if name == "AC_journal_capture": + params["body"] = [["AC_set_var", {"name": "should_not_run", "value": 1}]] + with pytest.raises(PathNotAllowedError): + executor.execute_action([[name, params]], raise_on_error=True) + assert not path.exists() + + +def test_public_inspection_matches_cli_and_rejects_noninteger_limits(tmp_path, capsys): + import je_auto_control as ac + from je_auto_control.cli import main + path = tmp_path / "inspect.jsonl" + ac.execute_action_with_journal([["AC_set_var", {"name": "x", "value": 1}]], path, run_id="inspect") + assert main(["journal", str(path), "--limit", "1"]) == 0 + assert ac.inspect_journal(path, limit=1) == json.loads(capsys.readouterr().out) + for invalid in (True, 0, -1, 10001, 1.5, "2"): + with pytest.raises(ac.ActionJournalError): + ac.inspect_journal(path, limit=invalid) + + +@pytest.mark.parametrize("worker", ["parallel", "dag"]) +def test_worker_capture_keeps_variables_metadata_and_history_owner(tmp_path, worker): + from je_auto_control.utils.run_history import HistoryStore + from je_auto_control.utils.run_history.context import HistoryBinding, bound_history + from je_auto_control.utils.run_history.history_store import SOURCE_MANUAL + path = tmp_path / "worker.jsonl" + nested = [_capture(path, [["AC_get_var", {"name": "message"}]], run_id="worker", + device_id="device", session_id="session")] + action = ["AC_parallel", {"branches": [nested]}] if worker == "parallel" else [ + "AC_run_dag", {"definition": {"nodes": [{"id": "capture", "actions": nested}]}}] + store = HistoryStore(tmp_path / "history.sqlite") + try: + row_id = store.start_run(SOURCE_MANUAL, "controlled worker", "actions.json") + with bound_history(HistoryBinding(store, row_id)), execution_scope({"message": "owner"}): + result = executor.execute_action([action], raise_on_error=True) + assert "owner" in json.dumps(result) + events = read_events(path) + assert [event.status for event in events] == ["incomplete", "ok"] + assert all((event.run_id, event.device_id, event.session_id) == ("worker", "device", "session") + for event in events) + assert [(link.journal_run_id, link.journal_path) for link in store.journals_for_run(row_id)] == [ + ("worker", str(path.resolve()))] + finally: + store.close() + + +@pytest.mark.parametrize("field", ["run_id", "device_id", "session_id", "path"]) +def test_resolved_secrets_cannot_enter_capture_identifiers_or_destination(tmp_path, monkeypatch, field): + from je_auto_control.utils.action_journal import ActionJournalError + from je_auto_control.utils.secrets import default_secret_manager + monkeypatch.setattr(default_secret_manager, "get", lambda name: "fake-private-metadata") + path = tmp_path / ("leaked-${secrets.PRIVATE}.jsonl" if field == "path" else "metadata.jsonl") + metadata = {} if field == "path" else {field: "${secrets.PRIVATE}"} + with execution_scope(): + with pytest.raises(ActionJournalError, match="resolved secret"): + executor.execute_action([_capture(path, [["AC_set_var", {"name": "effect", "value": 1}]], **metadata)], + raise_on_error=True) + assert "effect" not in executor.variables + assert list(tmp_path.glob("*.jsonl")) == [] + + +def test_capture_rejects_positional_bodies_without_reinterpreting_variable_data(tmp_path, monkeypatch): + from je_auto_control.utils.secrets import default_secret_manager + monkeypatch.setattr(default_secret_manager, "get", lambda name: pytest.fail("data was reinterpreted")) + path = tmp_path / "positional.jsonl" + body = [["AC_set_var", {"name": "copied", "value": "${message}"}]] + with execution_scope({"message": "${secrets.PRIVATE}"}): + with pytest.raises(TypeError): + executor.execute_action([["AC_journal_capture", [body, str(path)]]], raise_on_error=True) + assert not path.exists() From 2613101eb0fca6fa21fc5fe9e633687bf1ab6328 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 02:24:02 +0800 Subject: [PATCH 021/294] Add observed journal runs and dedicated MCP tools --- CHANGELOG.md | 10 + Progress.md | 2 +- README.md | 6 +- README/README_zh-CN.md | 6 +- README/README_zh-TW.md | 6 +- architecture.md | 12 ++ architecture_explore.md | 57 ++--- docs/API_LIFECYCLE.md | 6 + docs/CAPABILITY_MATRIX.md | 5 +- .../Eng/doc/mcp_server/mcp_server_doc.rst | 35 ++- .../Eng/doc/new_features/new_features_doc.rst | 63 +++++- .../Zh/doc/mcp_server/mcp_server_doc.rst | 32 ++- .../Zh/doc/new_features/new_features_doc.rst | 49 ++++- docs/updates/2026-10.md | 9 + docs/updates/README.md | 3 +- examples/29_mcp_journal.py | 34 +++ examples/README.md | 7 + je_auto_control/__init__.py | 12 +- je_auto_control/actions.pyi | 4 +- .../utils/action_journal/__init__.py | 4 +- .../utils/action_journal/capture.py | 107 +++++++--- .../utils/action_journal/commands.py | 8 + .../utils/action_journal/inspection.py | 16 +- je_auto_control/utils/action_journal/run.py | 55 +++++ je_auto_control/utils/action_journal/store.py | 24 ++- .../utils/executor/action_executor.py | 5 +- je_auto_control/utils/mcp_server/__init__.py | 4 +- je_auto_control/utils/mcp_server/context.py | 3 + je_auto_control/utils/mcp_server/server.py | 8 +- .../utils/mcp_server/tools/__init__.py | 4 +- .../utils/mcp_server/tools/_base.py | 31 ++- .../utils/mcp_server/tools/_factories.py | 43 ++++ .../mcp_server/tools/_handlers_journal.py | 75 +++++++ .../utils/mcp_server/tools/_validation.py | 4 +- je_auto_control/utils/rbac/policy.py | 2 + test/unit_test/headless/_contract_sweep.py | 10 + .../headless/test_journal_observation.py | 185 ++++++++++++++++ test/unit_test/headless/test_mcp_journal.py | 200 ++++++++++++++++++ .../test_typed_dict_contract_samples.py | 33 +++ 39 files changed, 1062 insertions(+), 117 deletions(-) create mode 100644 examples/29_mcp_journal.py create mode 100644 je_auto_control/utils/action_journal/run.py create mode 100644 je_auto_control/utils/mcp_server/tools/_handlers_journal.py create mode 100644 test/unit_test/headless/test_journal_observation.py create mode 100644 test/unit_test/headless/test_mcp_journal.py create mode 100644 test/unit_test/headless/test_typed_dict_contract_samples.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 71816a86d..8b728adfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- MCP `ac_journal_capture` and `ac_journal_read` support structured journal + results, progress and cooperative cancellation. Recorded action failures set + `isError` while retaining the run metadata. `MCPToolResult` lets handlers mark + controlled failures without adding protocol flags to their JSON data. + `JournalRunOptions`/`execute_journal_run` add typed observers and path policies; + nested captures and joined workers retain these controls, and each append + rechecks the bound roots. Readers can cancel between validated records and + close the file before propagating the original signal. Existing capture + convenience signatures and ordinary tool-result dictionaries stay compatible. + - `AC_journal_capture` captures a nested body while retaining its owning executor, variables, strictness and control signals; structural validation and linting include the body. `AC_journal_read` and public `inspect_journal` diff --git a/Progress.md b/Progress.md index a53ebde6a..f919c66f3 100644 --- a/Progress.md +++ b/Progress.md @@ -14,7 +14,7 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的 MCP 工具/專用 GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的專用 GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 diff --git a/README.md b/README.md index dd30f777b..e20db34c5 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. --- -Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). `AC_journal_capture` accepts named parameters and runs a nested `body` on the owning executor without replacing its variables; `AC_journal_read` shares sanitized, bounded inspection with the CLI and public `inspect_journal`. Both commands are editable in Script Builder. Capture rejects identifiers or destinations containing secrets known to the run before history linking, file creation or action effects. MCP tools, dedicated GUI browsing and manual-run history controls remain pending integration. +Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). `AC_journal_capture` accepts named parameters and runs a nested `body` on the owning executor without replacing its variables; `AC_journal_read` shares sanitized, bounded inspection with the CLI and public `inspect_journal`. Both commands are editable in Script Builder. Capture rejects identifiers or destinations containing secrets known to the run before history linking, file creation or action effects. MCP `ac_journal_capture` and `ac_journal_read` provide structured results, progress and cooperative cancellation. Captured journal roots and observers survive nested captures and local workers; reads can cancel between validated records. `JournalRunOptions` and `execute_journal_run` expose the same controls in Python. Dedicated GUI browsing and manual-run history controls remain pending integration. Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. DAG calls retain the owner mask through complete node diagnostics, including standalone calls and custom local runners. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. Interrupted history runs are recorded as `error`, while journal steps remain `cancelled`; no error screenshot is taken. Cancellation propagates through non-strict execution, recovery/retry, callbacks and local workers. Hard exits retain their original signal even if history completion fails. @@ -295,7 +295,7 @@ under a relative name are written below that directory. | Surface | Start it with | Notes | |---|---|---| -| **MCP server** | `je_auto_control_mcp` (stdio) or `AC_start_mcp_http_server` | 680 tools for Claude Desktop / Claude Code / custom tool loops: the full `ac_*` surface plus short model-friendly aliases for common GUI actions. Speaks the stateless MCP 2026-07-28 beside the `initialize`-based revisions. Bearer auth, TLS, audit log, rate limit, plugin hot-reload, CI fake backend. | +| **MCP server** | `je_auto_control_mcp` (stdio) or `AC_start_mcp_http_server` | 682 tools for Claude Desktop / Claude Code / custom tool loops: the full `ac_*` surface plus short model-friendly aliases for common GUI actions. Speaks the stateless MCP 2026-07-28 beside the `initialize`-based revisions. Bearer auth, TLS, audit log, rate limit, plugin hot-reload, CI fake backend. | | **REST API** | `je_auto_control start-rest` | Bearer token, per-IP rate limit + lockout, SQLite audit hook, `/metrics`, `/openapi.json`, `/docs` Swagger UI, `/dashboard`. | | **TCP socket server** | `je_auto_control start-server` | Newline-framed JSON action lists. Binds `127.0.0.1` by default. | | **pytest plugin** | installed automatically | Fixtures plus a Gherkin step library for pytest-bdd / behave. | @@ -451,7 +451,7 @@ ignore synthetic input, and fall back silently when the driver is absent. | Resource | What's in it | |---|---| -| [`examples/`](examples/) | 28 self-contained scripts: screenshot + click, OCR, scheduler, remote desktop, agent loop, observability, recording, variables, hotkeys, triggers, reports, MCP, REST, secrets, plugins, computer use, Wayland, cross-host DAGs, chat-ops, pytest/BDD, anchor locators, action journals. | +| [`examples/`](examples/) | 29 self-contained scripts: screenshot + click, OCR, scheduler, remote desktop, agent loop, observability, recording, variables, hotkeys, triggers, reports, MCP, REST, secrets, plugins, computer use, Wayland, cross-host DAGs, chat-ops, pytest/BDD, anchor locators, action journals. | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | Full API reference, English and 中文. | | [architecture_explore.md](architecture_explore.md) | Every module's responsibility, layer by layer. | | [docs/CAPABILITY_MATRIX.md](docs/CAPABILITY_MATRIX.md) | Capability × platform matrix. | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 2c4056a5a..4a7cdf0c5 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -245,7 +245,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。`AC_journal_capture` 接受命名参数,在拥有者 executor 上执行嵌套 `body`,保留原变量作用域;`AC_journal_read` 与 CLI、公开的 `inspect_journal` 共用遮罩后的有界查询。两个命令都可在 Script Builder 编辑。捕获会在 history 链接、创建文件及执行动作前拒绝含有当次执行已知密钥值的标识符或目标路径。MCP 工具、专用 GUI 查询与手动执行的 history 控制仍待接入。 +实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。`AC_journal_capture` 接受命名参数,在拥有者 executor 上执行嵌套 `body`,保留原变量作用域;`AC_journal_read` 与 CLI、公开的 `inspect_journal` 共用遮罩后的有界查询。两个命令都可在 Script Builder 编辑。捕获会在 history 链接、创建文件及执行动作前拒绝含有当次执行已知密钥值的标识符或目标路径。MCP 的 `ac_journal_capture` 与 `ac_journal_read` 提供结构化结果、进度与协作式取消。捕获的日志根目录限制与 observer 会保留到嵌套捕获及本机 workers;查询可在逐条验证时取消。Python 可通过 `JournalRunOptions` 与 `execute_journal_run` 使用相同控制。专用 GUI 查询与手动执行的 history 控制仍待接入。 `${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。DAG 会将拥有者的遮罩保留到 node 诊断完成,包括独立调用与自定义 local runner。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。中止的 history 记为 `error`,journal 步骤保持 `cancelled`,不再获取错误截图。取消会穿过非严格执行、恢复/重试、回调与本机 worker;history 结算失败也不会取代原始硬中止信号。 @@ -276,7 +276,7 @@ je_auto_control version | 接口 | 启动方式 | 说明 | |---|---|---| -| **MCP 服务器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 680 个工具,供 Claude Desktop/Claude Code/自定义 tool loop 使用:完整的 `ac_*` 工具接口,以及常用 GUI 操作的短名称 alias。除了以 `initialize` 握手的各版协议,也支持无状态的 MCP 2026-07-28。Bearer 认证、TLS、审计日志、限流、插件热重载、CI 假后端。 | +| **MCP 服务器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 682 个工具,供 Claude Desktop/Claude Code/自定义 tool loop 使用:完整的 `ac_*` 工具接口,以及常用 GUI 操作的短名称 alias。除了以 `initialize` 握手的各版协议,也支持无状态的 MCP 2026-07-28。Bearer 认证、TLS、审计日志、限流、插件热重载、CI 假后端。 | | **REST API** | `je_auto_control start-rest` | Bearer token、按 IP 限流与锁定、SQLite 审计 hook、`/metrics`、`/openapi.json`、`/docs` Swagger UI、`/dashboard`。 | | **TCP socket 服务器** | `je_auto_control start-server` | 以换行分隔的 JSON 动作列表。默认绑定 `127.0.0.1`。 | | **pytest 插件** | 安装后自动生效 | 提供 fixture 与供 pytest-bdd/behave 使用的 Gherkin step library。 | @@ -413,7 +413,7 @@ Windows、macOS(pyobjc)与 X11(含 XWayland);纯 Wayland 会话的协 | 资源 | 内容 | |---|---| -| [`examples/`](../examples/) | 28 个自包含脚本:截图点击、OCR、调度器、远程桌面、agent loop、可观测性、录制、变量、热键、触发器、报表、MCP、REST、密钥、插件、computer use、Wayland、跨主机 DAG、chat-ops、pytest/BDD、锚点定位、动作日志。 | +| [`examples/`](../examples/) | 29 个自包含脚本:截图点击、OCR、调度器、远程桌面、agent loop、可观测性、录制、变量、热键、触发器、报表、MCP、REST、密钥、插件、computer use、Wayland、跨主机 DAG、chat-ops、pytest/BDD、锚点定位、动作日志。 | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | 完整 API 参考,含英文与中文。 | | [architecture_explore.md](../architecture_explore.md) | 逐层记录每个模块的职责。 | | [docs/CAPABILITY_MATRIX.md](../docs/CAPABILITY_MATRIX.md) | 能力 × 平台对照矩阵。 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index b2d9882fc..65ac7b235 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -244,7 +244,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。`AC_journal_capture` 接受具名參數,在擁有者 executor 上執行巢狀 `body`,保留原變數範圍;`AC_journal_read` 與 CLI、公開的 `inspect_journal` 共用遮罩後的有界查閱。兩個指令皆可在 Script Builder 編輯。擷取會在 history 連結、開檔及動作執行前拒絕含有當次執行已知秘密的識別碼或目的地。MCP 工具、專用 GUI 查閱與手動執行的 history 控制仍待接入。 +實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。`AC_journal_capture` 接受具名參數,在擁有者 executor 上執行巢狀 `body`,保留原變數範圍;`AC_journal_read` 與 CLI、公開的 `inspect_journal` 共用遮罩後的有界查閱。兩個指令皆可在 Script Builder 編輯。擷取會在 history 連結、開檔及動作執行前拒絕含有當次執行已知秘密的識別碼或目的地。MCP 的 `ac_journal_capture` 與 `ac_journal_read` 提供結構化結果、進度與合作式取消。擷取的日誌根目錄限制與 observer 會保留至巢狀擷取及本機 workers;查閱可在逐筆驗證時取消。Python 可透過 `JournalRunOptions` 與 `execute_journal_run` 使用相同控制。專用 GUI 查閱與手動執行的 history 控制仍待接入。 `${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。DAG 會將擁有者的遮罩保留到 node 診斷完成,包含獨立呼叫與自訂 local runner。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。中止的 history 記為 `error`,journal 步驟維持 `cancelled`,不再擷取錯誤截圖。取消會穿過非嚴格執行、復原/重試、回呼與本機 worker;history 結算失敗也不會取代原始硬中止訊號。 @@ -275,7 +275,7 @@ je_auto_control version | 介面 | 啟動方式 | 說明 | |---|---|---| -| **MCP 伺服器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 680 個工具,供 Claude Desktop/Claude Code/自訂 tool loop 使用:完整的 `ac_*` 工具介面,以及常用 GUI 操作的短名稱 alias。除了以 `initialize` 握手的各版協定,也支援無狀態的 MCP 2026-07-28。Bearer 驗證、TLS、稽核記錄、限流、外掛熱重載、CI 假後端。 | +| **MCP 伺服器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 682 個工具,供 Claude Desktop/Claude Code/自訂 tool loop 使用:完整的 `ac_*` 工具介面,以及常用 GUI 操作的短名稱 alias。除了以 `initialize` 握手的各版協定,也支援無狀態的 MCP 2026-07-28。Bearer 驗證、TLS、稽核記錄、限流、外掛熱重載、CI 假後端。 | | **REST API** | `je_auto_control start-rest` | Bearer token、逐 IP 限流與鎖定、SQLite 稽核 hook、`/metrics`、`/openapi.json`、`/docs` Swagger UI、`/dashboard`。 | | **TCP socket 伺服器** | `je_auto_control start-server` | 以換行分隔的 JSON 動作清單。預設綁 `127.0.0.1`。 | | **pytest 外掛** | 安裝後自動生效 | 提供 fixture 與供 pytest-bdd/behave 使用的 Gherkin step library。 | @@ -413,7 +413,7 @@ Windows、macOS(pyobjc)與 X11(含 XWayland);純 Wayland session 的 | 資源 | 內容 | |---|---| -| [`examples/`](../examples/) | 28 個自足腳本:截圖點擊、OCR、排程器、遠端桌面、agent loop、可觀測性、錄製、變數、熱鍵、觸發器、報表、MCP、REST、機密、外掛、computer use、Wayland、跨主機 DAG、chat-ops、pytest/BDD、錨點定位、動作日誌。 | +| [`examples/`](../examples/) | 29 個自足腳本:截圖點擊、OCR、排程器、遠端桌面、agent loop、可觀測性、錄製、變數、熱鍵、觸發器、報表、MCP、REST、機密、外掛、computer use、Wayland、跨主機 DAG、chat-ops、pytest/BDD、錨點定位、動作日誌。 | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | 完整 API 參考,含英文與中文。 | | [architecture_explore.md](../architecture_explore.md) | 逐層記錄每個模組的職責。 | | [docs/CAPABILITY_MATRIX.md](../docs/CAPABILITY_MATRIX.md) | 能力 × 平台對照矩陣。 | diff --git a/architecture.md b/architecture.md index e93f389dc..a299afa96 100644 --- a/architecture.md +++ b/architecture.md @@ -141,6 +141,18 @@ full-file validation and bounded sanitized snapshots across the facade, CLI and `drive_input` under RBAC. Linting walks body-bearing ordinary commands as well as blocks. A plain outer dry-run previews the capture command without opening a journal; Python/CLI run-level capture can record individual dry-run steps. +`action_journal/run.py` supplies frozen `JournalRunOptions` and the isolated +`execute_journal_run` service; the seven-parameter convenience API delegates to +it. Bindings retain observer/policy tuples through nested roots and local workers. +Policies intersect and are checked before linking and at each append; observers +run before a start and after durable completion, preserving pending failures. +The reader owns a closing generator and invokes count observers outside storage +error translation. MCP journal adapters use these hooks for progress/cancellation +and receive the server's live policy through `ToolCallContext`. This confines +nested journal operations; general nested action argument policies remain pending. +`MCPToolResult` keeps failure metadata outside its JSON dictionary fields. The +server sets `isError` and audit status from this explicit type while preserving +structured payloads; ordinary dictionaries cannot set protocol flags. `utils/run_history/context.py` binds an existing history row to execution. Scheduler/trigger/hotkey/webhook/email owners bind their own row; local parallel and DAG workers carry it explicitly. `journal_scope` attaches an idempotent diff --git a/architecture_explore.md b/architecture_explore.md index 5aa239095..cc7609bd9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,15 +19,15 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,089 | -| 程式碼總行數 | 163,186 | +| Python 模組總數(含周邊子專案) | 1,091 | +| 程式碼總行數 | 163,483 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 781 | -| 套件門面 `__all__` 公開名稱數 | 1,274 | +| 套件門面 `__all__` 公開名稱數 | 1,277 | | GUI 分頁數(`main_widget` 註冊) | 48 | -| MCP 工具數(`build_default_tool_registry()` 實測) | 680 | -| `test_*.py` 測試檔/測試函式 | 763 / 8,033 | -| 範例腳本 | 28 | +| MCP 工具數(`build_default_tool_registry()` 實測) | 682 | +| `test_*.py` 測試檔/測試函式 | 766 / 8,060 | +| 範例腳本 | 29 | **技術基線**:Python ≥ 3.10、MIT 授權、必要相依只有 `je_open_cv`/`opencv-python`/`pillow`/`mss`/ `defusedxml`/`cryptography`(加上各平台專屬的 pyobjc、python-Xlib);其餘全部是選用 extras @@ -92,7 +92,7 @@ USB/IP 協定、Prometheus 指標),以維持這條輕相依基線。 | 模式 | 落點 | 說明 | | --- | --- | --- | | **Strategy** | `wrapper/platform_wrapper.py` | 依 `sys.platform` 只匯入當前 OS 的 `keyboard`/`mouse`/`screen`/`recorder` 實作;Linux 再細分 Wayland/X11,Wayland 後端不可用時自動退回 XWayland 並記警告。新增平台不需要改 wrapper。 | -| **Facade** | `je_auto_control/__init__.py` | 把 1,274 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | +| **Facade** | `je_auto_control/__init__.py` | 把 1,277 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | | **Command** | `utils/executor/action_executor.py` | `Executor.event_dict` 是字串 → callable 的分派表;JSON 動作檔即指令序列,因此可錄製、序列化、重播、簽章。 | | **Observer** | `utils/callback/`、`utils/observer/`、`utils/triggers/` | 動作完成後觸發回呼;畫面出現/消失/變化與外部事件(webhook/IMAP/檔案)驅動腳本。 | | **Template Method** | `utils/generate_report/` | HTML/JSON/XML 三個產生器共用「收集紀錄 → 格式化 → 寫檔」骨架,各自實作渲染。 | @@ -154,7 +154,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `je_auto_control/__init__.py` | 2,001 | **套件門面**。集中匯入並再匯出 1,274 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | +| `je_auto_control/__init__.py` | 2,001 | **套件門面**。集中匯入並再匯出 1,277 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | | `je_auto_control/__main__.py` | 106 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | | `je_auto_control/cli.py` | 416 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`/`--journal`)、`journal`(依 run 篩選與有界遮罩快照)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 15,943 行。 +> 25 個套件、約 16,082 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -283,8 +283,8 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/dag/` | 584 | 跨主機 DAG 編排器;node 的完整錯誤處理綁定擁有者秘密遮罩,含獨立呼叫與自訂 local runner | | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | -| `utils/action_journal/` | 640 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;門面、AC 指令、Script Builder 與 CLI 擷取/有界查閱已接入,MCP/專用 GUI/手動 history 待補。 | -| `utils/executor/` | 9,705 | **核心**。`Executor` 指令分派表(781 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/action_journal/` | 780 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;門面、AC/MCP、Script Builder 與 CLI 擷取/有界查閱;observer/路徑政策隨巢狀根與 workers 保留,專用 GUI/手動 history 待補。 | +| `utils/executor/` | 9,704 | **核心**。`Executor` 指令分派表(781 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -495,7 +495,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.9 AI / Agent / LLM -> 13 個套件、約 23,689 行。 +> 13 個套件、約 23,845 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -508,7 +508,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/cua_action/` | 204 | 標準化 computer-use 動作結構(Anthropic/OpenAI → `AC_*`) | | `utils/llm/` | 372 | 自然語言 → action list 規劃器 + Anthropic/null 後端;規劃執行隔離頂層變數並沿用巢狀範圍 | | `utils/mcp_registry/` | 97 | MCP registry `server.json` 資訊清單產生(可被發現) | -| `utils/mcp_server/` | 19,167 | **無頭 MCP 伺服器**(16K LOC,預設註冊 680 個工具=661 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | +| `utils/mcp_server/` | 19,323 | **無頭 MCP 伺服器**(預設註冊 682 個工具=663 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | | `utils/tool_use_schema/` | 195 | 把 `AC_*` 指令匯出成 Claude/OpenAI 的 tool-use schema | | `utils/trajectory_eval/` | 132 | agent 軌跡評估:依評分規準為一次執行打分 | | `utils/vision/` | 538 | VLM 元素定位器(依描述找元素)+ Anthropic/OpenAI/null 後端 | @@ -631,7 +631,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.14 安全、機密與合規 -> 13 個套件、約 3,525 行。 +> 13 個套件、約 3,527 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -640,7 +640,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/governance/` | 242 | 治理:maker-checker 核准閘門與即時憑證租約 | | `utils/license_policy/` | 240 | 以 SBOM 元件評估 SPDX 授權允許/拒絕政策 | | `utils/provenance/` | 126 | SLSA 建置來源證明(in-toto v1) | -| `utils/rbac/` | 792 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | +| `utils/rbac/` | 794 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | | `utils/redaction/` | 508 | 截圖遮蔽層:規則偵測 + 政策 + 協調器(上傳 VLM 前先遮) | | `utils/sbom/` | 170 | SBOM(CycloneDX)產生 | | `utils/secret_ref/` | 238 | URI scheme 形式的值參照解析 | @@ -697,11 +697,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,705 行)— 執行核心 +#### `utils/executor/`(9,704 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,451 | `Executor` 類別與 `event_dict` 分派表(781 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `action_executor.py` | 8,450 | `Executor` 類別與 `event_dict` 分派表(781 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | | `flow_control.py` | 678 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | | `action_schema.py` | 160 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | @@ -709,23 +709,24 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 | | `flags.py` | 19 | 旗標讀取 `as_bool`:依拼法(`"true"`/`"yes"`/`"on"`/`"1"`)而非 Python 真值判斷,所以 JSON、CLI、MCP 傳來的 `"false"` 是關;所有 adapter 與流程指令共用。 | -#### `utils/mcp_server/`(19,167 行,680 個工具)— 最大子系統 +#### `utils/mcp_server/`(19,323 行,682 個工具)— 最大子系統 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `tools/_factories.py` | 9,063 | 工具工廠:每個函式回傳一個領域的 `MCPTool` 清單(把 `AC_*` 能力包成 MCP 工具)。 | +| `tools/_factories.py` | 9,106 | 工具工廠:每個函式回傳一個領域的 `MCPTool` 清單(把 `AC_*` 能力包成 MCP 工具)。 | | `tools/_handlers.py` | 547 | 把 MCP 工具呼叫橋接到 AutoControl 無頭 API 的 adapter;主題模組拆完之後這裡留的是資料/文字/HTTP 那一類與 WebRunner 橋接。 | | `tools/_handlers_qa.py` | 419 | 同一種 adapter,QA 主題:斷言 DSL、資料驅動、SQL/PDF/郵件/HTTP 步驟、codegen、視覺回歸、狀態機、flaky 偵測與隔離、suite runner、無障礙稽核、裝置矩陣、媒體斷言。從 `_handlers.py` 依主題拆出的第一塊(750 行上限);兩者互不引用。 | | `tools/_handlers_input.py` | 221 | 同一種 adapter,輸入主題:滑鼠、鍵盤、虛擬手把(ViGEm)。 | | `tools/_handlers_screen.py` | 344 | 同一種 adapter,螢幕主題:擷取、像素、影像與文字搜尋、螢幕錄影。 | | `tools/_handlers_system.py` | 578 | 同一種 adapter,桌面工作階段:視窗(狀態操作依正規化的後端成敗回覆)、行程與 shell、開檔、閒置與睡眠、音量、鎖定、輸入法狀態、欄位驗證與重試、色彩對比、變更排序、元件分類、剪貼簿。 | | `tools/_handlers_runs.py` | 110 | 同一種 adapter,執行主題:executor、執行歷史、錄製、動作檔。 | +| `tools/_handlers_journal.py` | 75 | 日誌擷取與查閱 adapter:進度、合作式取消及保留執行資料的受控失敗。 | | `tools/_handlers_scheduling.py` | 200 | 同一種 adapter,排程主題:排程器、觸發器、熱鍵常駐。 | | `tools/_handlers_remote.py` | 66 | 同一種 adapter,遠端桌面的 host 與 viewer。 | | `tools/_handlers_executor_bridge.py` | 1,429 | 252 個純委派(中位數 3 行,最長的 16 行全是參數簽章):每個都是 `from action_executor import _x` 再 `return _x(...)`,沒有分支邏輯。超過 750 行,理由記在 `Progress.md` 的豁免表(再切只能照 MCP 工廠領域分,會把同一種委派散進十幾個沒有語意邊界的檔)。 | | `tools/_handlers_locators.py` | 436 | 同一種 adapter,定位主題:無障礙樹、智慧等待、自我修復、螢幕觀察、座標空間、視覺與 OCR、影像去重、元件倉庫、A/B 定位。 | | `tools/_handlers_operations.py` | 629 | 同一種 adapter,營運主題:agent 與其記憶/追蹤、治理與合規、成本與遙測、失敗掛鉤、看門狗、速率限制、檢查點、核可、產物與資產、測試選擇與分片、佇列與 saga。 | -| `server.py` | 736 | JSON-RPC 2.0 over stdio 的最小 MCP 伺服器:連線範圍狀態、行內/併發分派、工具與 resource/prompt 處理器;握手時代的方法表(`_run_method`),兩個協定時代的逐請求分派在 `_stateless.py`。 | +| `server.py` | 740 | JSON-RPC 2.0 over stdio 的最小 MCP 伺服器:連線範圍狀態、行內/併發分派、工具與 resource/prompt 處理器;握手時代的方法表(`_run_method`),兩個協定時代的逐請求分派在 `_stateless.py`。 | | `http_transport.py` | 727 | MCP 的 HTTP 傳輸;宣告 2026-07-28 的請求走 `_http_stateless.py` 的標頭規則,不發 session。 | | `_http_stateless.py` | 185 | MCP 2026-07-28 在 Streamable HTTP 上的規則:`MCP-Protocol-Version`/`Mcp-Method`/`Mcp-Name` 必須與 body 相符(`=?base64?…?=` 先解碼),不符是 400+`HeaderMismatch`;版本與中繼資料錯誤 400、未知方法 404。純函式,由 `http_transport.py` 回覆。 | | `http_sessions.py` | 247 | MCP 的 HTTP 傳輸用的 session 身分:`Mcp-Session-Id` 註冊表,以及每個 session 那條常駐的 server→client SSE 串流。 | @@ -738,12 +739,12 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `prompts.py` | 220 | MCP prompt 目錄。 | | `fake_backend.py` | 187 | CI/無頭測試用的記憶體內假後端。 | | `plugin_watcher.py` | 168 | 檔案變更時熱重載外掛工具的背景 watcher。 | -| `tools/_base.py` | 153 | 工具註冊表的共用型別與輔助。 | -| `tools/_validation.py` | 122 | MCP 工具用到的 JSON Schema 子集驗證器。 | +| `tools/_base.py` | 182 | 工具註冊表的共用型別與輔助;JSON 相容的 MCPToolResult 分離受控失敗旗標與資料。 | +| `tools/_validation.py` | 124 | MCP 工具用到的 JSON Schema 子集驗證器。 | | `tools/plugin_tools.py` | 89 | 把外掛載入的 `AC_*` callable 包成 `MCPTool`。 | | `log_bridge.py` | 118 | 把 Python logging 記錄橋接成 MCP `notifications/message`;2026-07-28 的請求只收到自己設了 `logLevel` 時產生的記錄。 | | `audit.py` | 114 | MCP 工具呼叫稽核記錄。 | -| `context.py` | 70 | 傳給 opt-in 工具處理器的每次呼叫上下文。 | +| `context.py` | 73 | 傳給 opt-in 工具處理器的每次呼叫上下文,含取消、進度與伺服器路徑政策。 | | `rate_limit.py` | 48 | 工具呼叫的 token bucket 限流。 | | `__main__.py` | 92 | `je_auto_control_mcp` console script 進入點。 | @@ -1000,7 +1001,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `scripts/dev_release.py` | 只用標準函式庫的發佈輔助腳本 | dev 通道(PyPI 的 `je_auto_control_dev`)的版本與比對:`prepare` 把 `dev.toml` 寫成 `pyproject.toml`,版本取 PyPI 最新一版加一個 patch(`dev.toml` 的版本只是下限,不回寫 repo);`changed dist` 把建好的 wheel 跟 PyPI 最新一版逐檔比對,內容沒變就不上傳。只給 `dev.yml` 的 `publish-dev` job 與拋棄式的 checkout 用,因為 `prepare` 會蓋掉 `pyproject.toml`。 | | `docs/` | Sphinx(`API`/`Eng`/`Zh`/`getting_started`) | Read the Docs 文件。 | | `architecture_diagram/` | drawio + png | 既有的架構圖原始檔。 | -| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 628 個 `test_*.py`/6,673 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | +| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 766 個 `test_*.py`/8,060 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | --- @@ -1083,9 +1084,9 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | | `gui/` | 100 | 28,807 | -| `utils/mcp_server/` | 37 | 19,167 | +| `utils/mcp_server/` | 38 | 19,323 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,705 | +| `utils/executor/` | 8 | 9,704 | | `utils/usb/` | 17 | 4,816 | | `je_auto_control/`(頂層 3 檔) | 3 | 2,523 | | `utils/accessibility/` | 14 | 3,143 | @@ -1102,6 +1103,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 699 | 59,735 | -| **總計** | **1,083** | **163,121** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 700 | 59,877 | +| **總計** | **1,085** | **163,418** | diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index a2217a35b..6fa3f4400 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -20,6 +20,12 @@ of existing executor or history APIs. Explicit capture scopes reject identifiers and destinations containing values known to the enclosing run's resolved-secret mask before linking history or running actions. +`JournalRunOptions` and `execute_journal_run` add typed run controls without +expanding the existing seven-parameter convenience signature. Nested scopes +inherit observers and intersect policies; callbacks may run on joined worker +threads. `MCPToolResult` is an experimental JSON-compatible object result with +separate controlled-failure metadata. The MCP journal tools remain experimental +alongside the other B1 journal APIs. - Stable API removal requires a deprecation warning and two minor releases. - Beta API removal requires one release note and one minor release. diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index 09a79a66b..5cd869203 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -25,9 +25,8 @@ real OS runner for it. It must not be interpreted as a production guarantee. Hardware-backed results and known limitations should be attached to releases. Journal storage, executor capture, provenance links, facade/CLI entry points -and Script Builder commands have controlled tests. The journal work does not -claim native device acceptance. Dedicated browsing, manual history ownership -and MCP journal tools remain pending B1 integration. +and Script Builder commands, observed runs and MCP journal tools have controlled tests. The journal work does not +claim native device acceptance. Dedicated browsing and manual history ownership remain pending B1 integration. Linux Wayland is split: **capture is exercised by CI against a real compositor; input is exercised by CI against a real EI peer and a real portal.** diff --git a/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst b/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst index e85e4b5ac..ead505d58 100644 --- a/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst +++ b/docs/source/Eng/doc/mcp_server/mcp_server_doc.rst @@ -8,14 +8,35 @@ custom Anthropic / OpenAI tool-use loops) can drive the host machine through AutoControl. Implementation is stdlib-only — JSON-RPC 2.0 over stdio or HTTP+SSE — no extra runtime dependencies. -Roughly 90 tools are exposed, plus the full set of MCP protocol +The default registry exposes 682 tools (663 canonical names and 19 aliases), +plus the full set of MCP protocol capabilities: tools, resources, prompts, sampling, roots, logging, progress, cancellation, list-changed notifications, and elicitation. +Structured journals +=================== + +``ac_journal_capture`` accepts ``actions``, ``path`` and optional ``metadata`` +(``run_id``, ``device_id``, ``session_id``), ``variables`` and ``dry_run``. +``ac_journal_read`` accepts ``path``, optional ``run_id`` and ``limit`` (default +100, range 1–10,000). Both have output schemas and support progress and +cooperative cancellation. Both require ``drive_input`` under RBAC; read-only +mode exposes read while omitting capture. Journal paths follow the MCP policy +and the default journal-root guard. See the journal guide for nested capture, +worker inheritance and read-cancellation boundaries. + +Capture returns ``run_id``, ``journal_path``, ``record`` and ``failed_actions``; +read returns ``{"events": [...]}``. A recorded executor failure sets ``isError`` +while retaining structured run data. For extension handlers, ``MCPToolResult`` +is a JSON-compatible dictionary with separate ``is_error``/``error_text`` +metadata. Only this explicit type controls tool error/audit status; data fields +in an ordinary dictionary cannot set protocol flags. Controlled failure results +retain their payload without an exception/error screenshot. + Tool catalogue ============== -The default registry pairs every canonical ``ac_*`` tool with a +The default registry gives selected common ``ac_*`` tools a short alias (``click``, ``type``, ``screenshot``, ...) so prompts can stay terse. Use ``--list-tools`` (see *CLI inspection* below) to dump the live catalogue as JSON. @@ -154,7 +175,7 @@ Logging notifications, progress, cancellation ``io.modelcontextprotocol/logLevel`` (see `Stateless requests (2026-07-28)`_). - Long-running tools that accept a ``ctx`` parameter receive a - :class:`ToolCallContext` and can call + ``ToolCallContext`` and can call ``ctx.progress(value, total, message)`` to push ``notifications/progress`` (when the client supplied a ``progressToken``) and ``ctx.check_cancelled()`` to abort @@ -461,7 +482,7 @@ Read-only / safe mode Set ``JE_AUTOCONTROL_MCP_READONLY=1`` (or pass ``--read-only`` to ``je_auto_control_mcp``, or ``read_only=True`` to -:func:`build_default_tool_registry`) to drop every tool whose +``build_default_tool_registry``) to drop every tool whose ``readOnlyHint`` is false. Only observers (positions, OCR queries, clipboard reads, history, ...) survive: @@ -546,7 +567,7 @@ reach: is readable, as before — including the ones that hold API keys. Programmatically, the same policy is ``server.argument_policy`` -(:class:`ArgumentPolicy` holding a :class:`je_auto_control.PathPolicy` and +(``ArgumentPolicy`` holding a ``je_auto_control.PathPolicy`` and the allowlist); assign another to a server you build yourself. Confirmation prompts (elicitation) @@ -622,7 +643,7 @@ returned to the model — fast forensic trail for flaky automations. Rate limiting ============= -Pass a :class:`RateLimiter` to :class:`MCPServer` to guard against +Pass a ``RateLimiter`` to ``MCPServer`` to guard against runaway loops: .. code-block:: python @@ -640,7 +661,7 @@ Plugin hot-reload ================= Drop ``*.py`` files exposing top-level ``AC_*`` callables into a -directory and let :class:`PluginWatcher` keep the registry in sync: +directory and let ``PluginWatcher`` keep the registry in sync: .. code-block:: python diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 1a2e35578..1ef992851 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1479,8 +1479,9 @@ Structured action journals (partial integration) B1 exports storage and explicit capture/read APIs from ``je_auto_control`` and ``je_auto_control.utils.action_journal``. CLI capture and bounded snapshot reads -are available, along with action commands and Script Builder forms. MCP tools, -dedicated GUI browsing and manual-run history controls remain pending integration. The journal/provenance APIs remain experimental during B1. +are available, along with action commands, Script Builder forms and dedicated +MCP capture/read tools. Dedicated GUI browsing and manual-run history controls +remain pending integration. The journal/provenance APIs remain experimental during B1. Applications can explicitly write schema-v1 snapshots:: import time @@ -1581,6 +1582,64 @@ and limit. ``examples/28_action_journal.py`` demonstrates variable-only capture and inspection without desktop input. +Observed runs and MCP integration +---------------------------------- + +``execute_journal_run(actions, path, options=JournalRunOptions(...))`` starts +fresh variables and returns the existing four-field ``JournalRunResult``. +Options group ``variables``, ``dry_run``, ``run_id``, ``device_id``, +``session_id``, ``observer`` and ``path_policy``. The original seven-parameter +``execute_action_with_journal`` API delegates to this runner and keeps its +signature. For example:: + + from pathlib import Path + from je_auto_control import JournalRunOptions, execute_journal_run + + statuses = [] + result = execute_journal_run( + [["AC_set_var", {"name": "message", "value": "hello"}]], + Path("run.jsonl"), + options=JournalRunOptions(run_id="observed", observer=statuses.append), + ) + # statuses == ["incomplete", "ok"] + +Observers receive a status string before a step's durable start and after its +completion snapshot is persisted. They may raise to stop execution. A secondary +completion-observer failure cannot replace a pending original error or control +signal. Joined workers invoke observers on their own threads. Nested captures +retain enclosing observers and intersect path policies, including captures +opened by existing convenience APIs. The bound policy's live roots are checked +at each append. A refused completion leaves the durable start incomplete. + +``inspect_journal`` and ``read_events`` accept count observers for cooperative +read cancellation. Counts include every validated record before filtering; +file handles close before callback exceptions propagate. ``inspect_journal`` +also intersects enclosing journal policies and an optional explicit policy. + +MCP exposes ``ac_journal_capture`` and ``ac_journal_read``. Capture accepts +``actions``, ``path``, optional ``metadata`` (``run_id``, ``device_id``, +``session_id``), ``variables`` and ``dry_run``. It reports durable completed-step +counts, observes cancellation at action boundaries and returns structured run +metadata. Recorded executor failures set ``isError: true`` while preserving +that metadata and the original record. Domain outcomes such as a failed DAG +node remain in ``record`` and require separate inspection. + +Read accepts ``path``, optional ``run_id`` and ``limit`` and returns +``{"events": [...]}``. It checks cancellation between validated records, +reports scan progress every 100 records and sends a final count. Limits bound +output, while the whole file is validated. Both tools require ``drive_input`` +under RBAC. Read-only mode keeps read and omits capture. + +The MCP context carries the server's path policy into journal operations, +including nested journal commands and local workers. Default journal roots +must also allow the path; configure ``AUTOCONTROL_ALLOWED_PATH_ROOTS`` for an +additional destination alongside MCP roots. General nested-action argument +policy integration remains tracked in ``Progress.md``. Cancellation is +cooperative at the described boundaries; running native I/O is not forcibly +interrupted. ``examples/29_mcp_journal.py`` demonstrates the JSON-RPC workflow +in process without desktop input or an external service. + + History provenance links ------------------------ diff --git a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst index c3fd91131..536900908 100644 --- a/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst +++ b/docs/source/Zh/doc/mcp_server/mcp_server_doc.rst @@ -8,14 +8,30 @@ OpenAI tool-use 迴圈)都能透過 AutoControl 操控本機桌面。實作純 準函式庫:JSON-RPC 2.0 走 stdio 或 HTTP+SSE,不需要額外的執行階段 依賴。 -預設暴露約 90 個工具,並支援完整的 MCP 協定能力:tools、resources、 +預設暴露 682 個工具(663 個正式名稱與 19 個別名),並支援 MCP 協定能力:tools、resources、 prompts、sampling、roots、logging、progress、cancellation、 list-changed 通知與 elicitation。 +結構化日誌 +========== + +``ac_journal_capture`` 接受 ``actions``、``path``,以及可選的 ``metadata`` +(``run_id``、``device_id``、``session_id``)、``variables`` 與 ``dry_run``。 +``ac_journal_read`` 接受 ``path``、可選的 ``run_id`` 與 ``limit``(預設 100,範圍 +1–10,000)。兩者都有 output schema,支援進度與合作式取消。RBAC 下都要求 +``drive_input``;唯讀模式保留查閱、移除擷取。日誌路徑同時受 MCP 政策與預設 journal +根目錄限制;巢狀擷取、worker 繼承與查閱取消邊界詳見日誌指南。 + +擷取回傳 ``run_id``、``journal_path``、``record`` 與 ``failed_actions``;查閱回傳 +``{"events": [...]}``。executor 記錄失敗時設定 ``isError``,並保留結構化 run 資料。 +擴充 handler 可回傳 ``MCPToolResult``:它是 JSON 相容的字典,另帶 ``is_error``/ +``error_text`` metadata。只有此明確型別會控制工具錯誤/audit 狀態;一般字典中的資料 +欄位無法設定協定旗標。受控失敗會保留資料,不透過例外或錯誤截圖回報。 + 工具目錄 ======== -預設註冊表會把每個正式 ``ac_*`` 工具同時註冊一個短別名(``click``、 +預設註冊表會為部分常用的正式 ``ac_*`` 工具註冊短別名(``click``、 ``type``、``screenshot``...),提示文字可以更精簡。要看實際目錄請 用下方「CLI 檢視」段落的 ``--list-tools``。 @@ -139,7 +155,7 @@ Logging 通知 / Progress / Cancellation ``io.modelcontextprotocol/logLevel`` 時才收到(見 `無狀態請求 (2026-07-28)`_)。 - 接受 ``ctx`` 參數的長時間工具會收到 - :class:`ToolCallContext`:呼叫 + ``ToolCallContext``:呼叫 ``ctx.progress(value, total, message)`` 推送 ``notifications/progress``(client 須提供 ``progressToken``);呼 叫 ``ctx.check_cancelled()`` 在收到 ``notifications/cancelled`` @@ -395,7 +411,7 @@ client 不用改,可以和 2026-07-28 的 client 並存。 =============== 設定 ``JE_AUTOCONTROL_MCP_READONLY=1``(或對 ``je_auto_control_mcp`` 加上 -``--read-only``,或呼叫 :func:`build_default_tool_registry` 時傳 ``read_only=True``)只暴 +``--read-only``,或呼叫 ``build_default_tool_registry`` 時傳 ``read_only=True``)只暴 露 ``readOnlyHint`` 為 true 的工具(座標、OCR 查詢、剪貼簿讀取、歷 程等): @@ -468,8 +484,8 @@ client 不用改,可以和 2026-07-28 的 client 並存。 只有名稱符合時才會解析,其餘回工具執行錯誤。沒設時和以前一樣,任何變數都讀得到—— 包括放 API 金鑰的那些。 -在程式裡,同一份設定是 ``server.argument_policy`` ( :class:`ArgumentPolicy` ,內含 -:class:`je_auto_control.PathPolicy` 與允許清單);自己建立的伺服器可以指派另一個。 +在程式裡,同一份設定是 ``server.argument_policy`` ( ``ArgumentPolicy`` ,內含 +``je_auto_control.PathPolicy`` 與允許清單);自己建立的伺服器可以指派另一個。 破壞性動作確認(Elicitation) ============================= @@ -531,7 +547,7 @@ auto-screenshot 路徑(見下)。 Rate Limiting ============= -把 :class:`RateLimiter` 傳給 :class:`MCPServer` 防止失控的迴圈灌 +把 ``RateLimiter`` 傳給 ``MCPServer`` 防止失控的迴圈灌 爆主機: .. code-block:: python @@ -548,7 +564,7 @@ Plugin Hot-Reload ================= 把暴露頂層 ``AC_*`` callable 的 ``*.py`` 丟進資料夾,讓 -:class:`PluginWatcher` 自動同步 registry: +``PluginWatcher`` 自動同步 registry: .. code-block:: python diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index cceda8fdb..96bad2006 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1375,8 +1375,8 @@ GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher B1 的儲存與明確擷取/查閱 API 已從 ``je_auto_control`` 匯出,也可透過 ``je_auto_control.utils.action_journal`` 使用。CLI 擷取與有界快照查閱已接入; -動作指令與 Script Builder 表單也已接入;MCP 工具、專用 GUI 查閱與手動執行的 history 控制 -仍待接入。B1 整合期間,日誌與來源連結 API +動作指令、Script Builder 表單與專用 MCP 擷取/查閱工具也已接入;專用 GUI 查閱與手動 +執行的 history 控制仍待接入。B1 整合期間,日誌與來源連結 API 仍屬實驗性介面。應用程式可明確寫入 schema-v1 快照:: import time @@ -1460,6 +1460,51 @@ body;Data 類別的 **Read Action Journal** 可編輯路徑、run 篩選與輸 ``examples/28_action_journal.py`` 示範只操作變數的擷取與查閱,不送出桌面輸入。 +可觀察執行與 MCP 接線 +------------------------ + +``execute_journal_run(actions, path, options=JournalRunOptions(...))`` 建立全新變數範圍, +回傳既有四欄位的 ``JournalRunResult``。options 整合 ``variables``、``dry_run``、 +``run_id``、``device_id``、``session_id``、``observer`` 與 ``path_policy``。 +原本七參數的 ``execute_action_with_journal`` 會委派至此執行器,保留既有簽章。例如:: + + from pathlib import Path + from je_auto_control import JournalRunOptions, execute_journal_run + + statuses = [] + result = execute_journal_run( + [["AC_set_var", {"name": "message", "value": "hello"}]], + Path("run.jsonl"), + options=JournalRunOptions(run_id="observed", observer=statuses.append), + ) + # statuses == ["incomplete", "ok"] + +Observer 在步驟起始快照持久化前與完成快照持久化後收到狀態字串,可丟出例外中止執行。 +完成 observer 的次要失敗不會取代原本正在傳遞的錯誤或控制訊號。本機 workers 會在各自的 +執行緒呼叫 observer。巢狀擷取保留外層 observer 並交集路徑政策,包含既有便利 API 開啟的 +擷取。每次附加都會檢查綁定政策當下的根目錄;完成寫入遭拒時,保留已持久化的未完成起始。 + +``inspect_journal`` 與 ``read_events`` 支援筆數 observer,可合作式取消查閱。計數包含 +篩選前每筆已驗證紀錄;callback 例外傳遞前會關閉檔案。``inspect_journal`` 另會交集 +外層 journal 政策與可選的明確政策。 + +MCP 提供 ``ac_journal_capture`` 與 ``ac_journal_read``。擷取接受 ``actions``、``path``, +以及可選的 ``metadata``(``run_id``、``device_id``、``session_id``)、``variables`` 與 +``dry_run``。它回報已持久化完成的步驟數,在動作邊界觀察取消,並回傳結構化 run 來源資料。 +executor 記錄的失敗會設定 ``isError: true``,同時保留來源資料與原始 record。DAG node +失敗等領域結果仍位於 ``record``,須另外檢視。 + +查閱接受 ``path``、可選的 ``run_id`` 與 ``limit``,回傳 ``{"events": [...]}``。 +它在逐筆驗證時檢查取消,每 100 筆回報掃描進度,結束時回報最終筆數。limit 限制輸出, +整份檔案仍會驗證。兩個工具在 RBAC 下都要求 ``drive_input``;唯讀模式保留查閱、移除擷取。 + +MCP context 會將伺服器的路徑政策帶入日誌操作,包含巢狀日誌指令與本機 workers。 +預設 journal 根目錄也須允許該路徑;其他目的地除了 MCP roots,也需設定 +``AUTOCONTROL_ALLOWED_PATH_ROOTS``。一般巢狀動作的引數政策接線仍追蹤於 ``Progress.md``。 +取消在上述邊界合作進行,不會強制中斷已執行的原生 I/O。 +``examples/29_mcp_journal.py`` 以同一行程示範 JSON-RPC 流程,不送出桌面輸入或呼叫外部服務。 + + 執行歷史的日誌來源連結 ------------------------ diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 38a4dd603..b5e55d4e8 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -480,3 +480,12 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: the full suite passes 11,807 tests with 69 skipped and 87.78% coverage, including all 24 new command/form cases. 12 entry-point cases failed for missing commands/API; four metadata cases and one positional-body case separately reproduced privacy/data-reinterpretation gaps. The final adapter/command/CLI/capture/history selection passes 1,892 tests with six skipped; offscreen form/preservation selection passes 16, and stub/packaging/map checks pass 36. Tests cover owner/worker scopes and history, original strict exceptions, soft failures, loop break, cancellation, dry-run, preflight/lint rejection, roles, output bounds and default-root confinement. Runtime body annotations remain available to the registry introspector. The variable-only example produces four expected snapshots. Three journal modules pass strict mypy and whole-package three-platform typing has zero errors. Ruff and bandit pass; the two new modules score 10.00/10 in pylint and radon reports no journal function above complexity B. Nitpicky English/Chinese Sphinx and isolated wheel/source builds pass; all 12 changed Python modules and the generated stub match both archives, as does the sdist README. Jeffrey_RPA facade/control suites pass 740 tests against this source tree (five existing Discord deprecations). - **Files and docs**: journal adapters/inspection/capture, executor/schema, RBAC, linter, Script Builder schemas, facade/CLI, regenerated command stubs and their stdlib Path import, headless/offscreen regressions, example 28, three READMEs, API lifecycle/capability matrix, bilingual journal guide, changelog and measured architecture maps (781 commands, 1,274 facade names, 28 examples). - **Open items**: B1 dedicated MCP tools, GUI journal browsing and manual-run history controls remain in `Progress.md`, along with the existing general MCP nested-path/argument-policy work and the wider modernization plan. Native and paid-API acceptance remain deferred. + +## U-20261019-04 · 2026-10-19 · Add observed journal runs and dedicated MCP operations · #done #journal #mcp + +- **What**: `JournalRunOptions` and `execute_journal_run` add isolated execution with inherited status observers and intersected path policies; the existing seven-parameter convenience API retains its contract. Observers see starts before dispatch and completions after durable append, including nested roots and joined local workers. Revalidation before each append honors live client-root changes. Secondary completion/observer errors preserve a pending original failure or hard signal. Reads check cancellation for every validated snapshot before filtering and close their handle before propagating callback failures. +- **MCP**: dedicated `ac_journal_capture` and `ac_journal_read` tools expose structured results, metadata, bounded sanitized inspection and progress/cancellation. Both require `drive_input` under RBAC; read-only registries retain only inspection. Default journal roots and server/client roots both apply, including dynamically selected nested journal destinations. Explicit `MCPToolResult` metadata sets protocol/audit error status while retaining result data; arbitrary dictionary keys cannot spoof that status. Recorded executor failures set `isError`; domain result flags remain in `record` under the existing executor contract. +- **Boundary corrections**: explicit `additionalProperties: false` is now enforced in nested input objects, preventing unknown journal labels from being silently discarded. The adapter sweep's sample generator now populates required `TypedDict` fields rather than supplying an invalid empty dictionary; separate regressions cover required, inherited optional and recursive contracts, with no adapter exclusions or relaxed assertions. +- **Validation**: the final full suite passes 11,848 tests with 69 skipped and 87.82% coverage. The final focused regression/map/budget selection passes 74 tests. 38 new cases pass, including 18 pre-implementation observation failures and the original 15 missing-MCP-tool cases. The adapter/registry/stub selection passes 1,863 tests with six skipped; 73 documentation, budget, complexity, exception, SQLite and light-import gates pass. Five journal/handler modules pass strict mypy, and whole-package three-platform typing reports zero errors. Ruff and bandit pass; new runner/handler pylint scores 10.00/10 under the project limits, and full-package radon reports no function above complexity B. Full-library pylint also ran and reported style/Qt-import diagnostics (7.88/10 with default settings); this is not a clean global lint gate. Nitpicky English/Chinese Sphinx and isolated wheel/source builds pass; all 18 changed production Python/stub files match both archives and the sdist README matches the source. The variable-only JSON-RPC example produces two expected snapshots. Jeffrey_RPA facade/control suites pass 740 tests against this source tree (five existing Discord deprecations). +- **Files and docs**: journal runner/capture/inspection/storage, MCP context/result/handlers/registry/validation, RBAC, facade exports and regenerated action stub, adapter contract samples, regression tests, example 29, three READMEs, lifecycle/capability matrix, bilingual journal/MCP guides, changelog and measured architecture maps (1,277 facade names, 682 MCP names including 663 canonical tools and 19 aliases). +- **Open items**: B1 dedicated GUI, manual-run history controls and journal browsing remain in `Progress.md`, along with general MCP nested-action argument-policy integration and the wider modernization plan. Native-device and paid-API acceptance remain deferred at the user's request. diff --git a/docs/updates/README.md b/docs/updates/README.md index 17836453b..b29cb4729 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261019-04 | 2026-10-19 | Add observed journal runs and dedicated MCP operations | #done #journal #mcp | [2026-10](2026-10.md) | | U-20261019-03 | 2026-10-19 | Add scoped journal commands and shared safe inspection | #done #journal #executor | [2026-10](2026-10.md) | | U-20261019-02 | 2026-10-19 | Expose journal capture and bounded CLI inspection | #done #journal #cli | [2026-10](2026-10.md) | | U-20261019-01 | 2026-10-19 | Keep complete DAG node diagnostics inside their owner mask | #incident #security #dag | [2026-10](2026-10.md) | @@ -385,7 +386,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 49 | +| [2026-10.md](2026-10.md) | 2026-10 | 50 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/examples/29_mcp_journal.py b/examples/29_mcp_journal.py new file mode 100644 index 000000000..040fbb71f --- /dev/null +++ b/examples/29_mcp_journal.py @@ -0,0 +1,34 @@ +"""Capture and inspect variable-only actions through an in-process MCP client. + +Run: python examples/29_mcp_journal.py run.jsonl +No desktop input, external transport or paid API is used. +""" +import argparse +import json +from pathlib import Path + +from je_auto_control import MCPServer + + +def main() -> None: + """Send two JSON-RPC calls and print the sanitized journal snapshots.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("journal", type=Path) + args = parser.parse_args() + server = MCPServer() + requests = [ + ("ac_journal_capture", {"path": str(args.journal), "metadata": {"run_id": "mcp-example"}, + "actions": [["AC_set_var", {"name": "message", "value": "hello"}]]}), + ("ac_journal_read", {"path": str(args.journal), "run_id": "mcp-example"}), + ] + for request_id, (name, arguments) in enumerate(requests, 1): + line = server.handle_line(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "tools/call", + "params": {"name": name, "arguments": arguments}})) + reply = json.loads(line) + if "error" in reply or reply["result"]["isError"]: + raise SystemExit(json.dumps(reply)) + print(json.dumps(reply["result"]["structuredContent"], indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index fe97af1e4..acfb3d6e6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,6 +44,13 @@ glue between AutoControl's public API and your code. ## Running +[`29_mcp_journal.py`](29_mcp_journal.py) sends capture/read JSON-RPC requests to +an in-process MCP server with variable-only actions: + +```bash +python examples/29_mcp_journal.py run.jsonl +``` + [`28_action_journal.py`](28_action_journal.py) captures variable-only actions through `AC_journal_capture` and prints sanitized snapshots with `inspect_journal`; it sends no desktop input: diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 60a67aa14..eb621a36e 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -1081,7 +1081,7 @@ # MCP server (headless stdio bridge for Claude / other MCP clients) from je_auto_control.utils.mcp_server import ( AuditLogger, HttpMCPServer, MCPContent, MCPPrompt, MCPPromptArgument, - MCPResource, MCPServer, MCPTool, MCPToolAnnotations, + MCPResource, MCPServer, MCPTool, MCPToolAnnotations, MCPToolResult, OperationCancelledError, PromptProvider, RateLimiter, ResourceProvider, ToolCallContext, build_default_tool_registry, default_prompt_provider, default_resource_provider, @@ -1159,8 +1159,8 @@ ) # Structured action provenance (headless, explicit capture only) from je_auto_control.utils.action_journal import ( - ActionEvent, ActionJournal, ActionJournalError, JournalBinding, JournalRunResult, - execute_action_with_journal, inspect_journal, journal_scope, read_events, + ActionEvent, ActionJournal, ActionJournalError, JournalBinding, JournalRunOptions, JournalRunResult, + execute_action_with_journal, execute_journal_run, inspect_journal, journal_scope, read_events, ) # Triggers (headless) from je_auto_control.utils.triggers.trigger_engine import ( @@ -1758,7 +1758,7 @@ def start_autocontrol_gui(*args, **kwargs): # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", - "MCPToolAnnotations", "OperationCancelledError", "PromptProvider", + "MCPToolAnnotations", "MCPToolResult", "OperationCancelledError", "PromptProvider", "RateLimiter", "ResourceProvider", "ToolCallContext", "build_default_tool_registry", "default_prompt_provider", "default_resource_provider", @@ -1812,8 +1812,8 @@ def start_autocontrol_gui(*args, **kwargs): "Tracer", "default_tracer", "traced", # Run history "HistoryStore", "JournalLink", "RunRecord", "default_history_store", - "ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunResult", - "execute_action_with_journal", "inspect_journal", "journal_scope", "read_events", + "ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunOptions", "JournalRunResult", + "execute_action_with_journal", "execute_journal_run", "inspect_journal", "journal_scope", "read_events", # Accessibility "AccessibilityElement", "AccessibilityNotAvailableError", "accessibility_status", diff --git a/je_auto_control/actions.pyi b/je_auto_control/actions.pyi index ba6e0d5a7..cba229da5 100644 --- a/je_auto_control/actions.pyi +++ b/je_auto_control/actions.pyi @@ -1565,8 +1565,8 @@ def AC_journal_capture( ) -> Any: """Capture named body arguments on their executor without eager interpolation.""" -def AC_journal_read(path: str | Path, *, run_id: Optional[str] = ..., limit: int = ...) -> list[dict[str, object]]: - """Validate a whole journal, then return at most ``limit`` sanitized snapshots.""" +def AC_journal_read(path: str | Path, *, run_id: str | None = ..., limit: int = ...) -> list[dict[str, object]]: + """Return bounded sanitized snapshots under the enclosing journal roots.""" def AC_json_extract(data: Any, mapping: Any) -> Dict[str, Any]: """Adapter: extract a {key: path} mapping from data into a flat dict.""" diff --git a/je_auto_control/utils/action_journal/__init__.py b/je_auto_control/utils/action_journal/__init__.py index 0fde164a1..2201abb3a 100644 --- a/je_auto_control/utils/action_journal/__init__.py +++ b/je_auto_control/utils/action_journal/__init__.py @@ -5,6 +5,8 @@ from je_auto_control.utils.action_journal.capture import ( JournalBinding, JournalRunResult, execute_action_with_journal, journal_scope, ) +from je_auto_control.utils.action_journal.run import JournalRunOptions, execute_journal_run __all__ = ["ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunResult", - "read_events", "inspect_journal", "execute_action_with_journal", "journal_scope"] + "read_events", "inspect_journal", "execute_action_with_journal", "journal_scope", + "JournalRunOptions", "execute_journal_run"] diff --git a/je_auto_control/utils/action_journal/capture.py b/je_auto_control/utils/action_journal/capture.py index d73860343..695540042 100644 --- a/je_auto_control/utils/action_journal/capture.py +++ b/je_auto_control/utils/action_journal/capture.py @@ -6,7 +6,7 @@ from contextvars import ContextVar from dataclasses import dataclass, replace from pathlib import Path -from typing import Iterator, Mapping, Optional, Sequence, TypedDict, cast +from typing import Callable, Iterator, Mapping, Optional, Sequence, TypedDict, cast from uuid import uuid4 from je_auto_control.utils.action_journal.events import ActionEvent, ActionJournalError, EventStatus @@ -14,8 +14,8 @@ from je_auto_control.utils.action_journal.store import ActionJournal from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.logging.secret_scope import scrub_secret_text -from je_auto_control.utils.path_guard.path_guard import validate_path -from je_auto_control.utils.script_vars.execution import execution_scope +from je_auto_control.utils.path_guard.policy import PathPolicy +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.run_history.context import current_history from je_auto_control.utils.run_history.run_outcome import is_run_interruption @@ -29,6 +29,8 @@ class JournalBinding: parent_id: Optional[str] = None device_id: Optional[str] = None session_id: Optional[str] = None + observers: tuple[Callable[[EventStatus], None], ...] = () + path_policies: tuple[PathPolicy, ...] = () class JournalRunResult(TypedDict): @@ -65,26 +67,55 @@ def bound_journal(binding: Optional[JournalBinding]) -> Iterator[None]: @contextmanager def journal_scope(journal: ActionJournal, *, run_id: Optional[str] = None, - device_id: Optional[str] = None, session_id: Optional[str] = None) -> Iterator[JournalBinding]: + device_id: Optional[str] = None, session_id: Optional[str] = None, + observer: Optional[Callable[[EventStatus], None]] = None, + path_policy: Optional[PathPolicy] = None) -> Iterator[JournalBinding]: """Capture enclosed executor calls in one explicit run, restoring any outer run. Worker helpers carry the binding explicitly. A new scope starts a new root; nested action bodies share the current binding until their call returns. Identifiers and destinations containing secrets known to the enclosing run are refused before history linking, file creation or body execution. + Nested scopes retain enclosing observers and intersect path policies. + Observers receive ``incomplete`` before dispatch and the final status only + after durable completion; joined workers invoke them on their own threads. """ + identity = _scope_identity(journal, run_id, device_id, session_id) + parent = current_journal() + observers = parent.observers if parent is not None else () + if observer is not None and all(item is not observer for item in observers): + observers += (observer,) + policies = _effective_path_policies(path_policy) + for policy in policies: + policy.validate(journal.path, operation="journal capture") + binding = JournalBinding(journal, identity, device_id=device_id, session_id=session_id, + observers=observers, path_policies=policies) + history = current_history() + if history is not None and not history.store.attach_journal(history.run_id, str(journal.path.resolve()), identity): + raise ActionJournalError("cannot attach journal to a missing history run") + with bound_journal(binding): + yield binding + + +def _scope_identity(journal: ActionJournal, run_id: Optional[str], device_id: Optional[str], + session_id: Optional[str]) -> str: + """Validate public metadata before any provenance link or file effect.""" identity = uuid4().hex if run_id is None else run_id if not isinstance(identity, str) or not identity: raise ActionJournalError("journal run ID must be a non-empty string") for value in (identity, device_id, session_id, str(journal.path)): if isinstance(value, str) and scrub_secret_text(value) != value: raise ActionJournalError("journal identifiers and destination cannot contain a resolved secret") - binding = JournalBinding(journal, identity, device_id=device_id, session_id=session_id) - history = current_history() - if history is not None and not history.store.attach_journal(history.run_id, str(journal.path.resolve()), identity): - raise ActionJournalError("cannot attach journal to a missing history run") - with bound_journal(binding): - yield binding + return identity + + +def _effective_path_policies(policy: Optional[PathPolicy]) -> tuple[PathPolicy, ...]: + """Intersect explicit roots with every enclosing captured run's roots.""" + parent = current_journal() + policies = parent.path_policies if parent is not None else () + if policy is not None and all(item is not policy for item in policies): + policies += (policy,) + return policies def _snapshot(action: list[object], binding: JournalBinding) -> ActionEvent: @@ -138,7 +169,8 @@ def action_step(action: list[object], *, dry_run: bool = False) -> Iterator[Opti yield None return event = _snapshot(action, binding) - binding.journal.append(event) # durable start precedes every device effect + _notify_observers(binding, event.status) + _append_event(binding, event) # durable start precedes every device effect step, before = _CapturedStep(), _failure_count() error: Optional[BaseException] = None with bound_journal(replace(binding, parent_id=event.step_id)): @@ -148,14 +180,37 @@ def action_step(action: list[object], *, dry_run: bool = False) -> Iterator[Opti error = failure raise finally: - try: - binding.journal.append(_completion(event, step, before, error, dry_run)) - except ActionJournalError: - if error is None: - raise - # Preserve a cancellation or original failure. Its durable start - # stays incomplete if completion could not be written. - autocontrol_logger.error("action journal completion unavailable; preserving original exception") + _finish_step(binding, _completion(event, step, before, error, dry_run), error) + + +def _append_event(binding: JournalBinding, event: ActionEvent) -> None: + """Check current roots at each append, including client-root revocation.""" + for policy in binding.path_policies: + policy.validate(binding.journal.path, operation="journal append") + binding.journal.append(event) + + +def _notify_observers(binding: JournalBinding, status: EventStatus, error: Optional[BaseException] = None) -> None: + """Notify observers while preserving any original failure or control signal.""" + try: + for observer in binding.observers: + observer(status) + except BaseException: # pylint: disable=broad-exception-caught # reason: retain the pending original signal + if error is None: + raise + autocontrol_logger.error("action journal observer unavailable; preserving original exception") + + +def _finish_step(binding: JournalBinding, event: ActionEvent, error: Optional[BaseException]) -> None: + """Notify completion only after persistence; retain a pending hard signal.""" + try: + _append_event(binding, event) + except AutoControlException: + if error is None: + raise + autocontrol_logger.error("action journal completion unavailable; preserving original exception") + else: + _notify_observers(binding, event.status, error) def execute_action_with_journal(action_list: Sequence[object] | Mapping[str, object], path: Path, *, @@ -168,13 +223,7 @@ def execute_action_with_journal(action_list: Sequence[object] | Mapping[str, obj run's failure-count delta. Direct private executors can use ``journal_scope`` to capture their existing scope without changing ownership of their variables. """ - # pylint: disable-next=import-outside-toplevel # reason: breaks the capture/executor import cycle - from je_auto_control.utils.executor.action_executor import executor - journal = ActionJournal(validate_path(path)) - with execution_scope(variables), journal_scope(journal, run_id=run_id, - device_id=device_id, session_id=session_id) as binding: - before = _failure_count() - actions = dict(action_list) if isinstance(action_list, Mapping) else list(action_list) - record: dict[str, object] = dict(executor.execute_action(actions, dry_run=dry_run)) - return {"run_id": binding.run_id, "journal_path": str(journal.path), "record": record, - "failed_actions": _failure_count() - before} + # pylint: disable-next=import-outside-toplevel # reason: the options runner imports this capture boundary + from je_auto_control.utils.action_journal.run import JournalRunOptions, execute_journal_run + return execute_journal_run(action_list, path, options=JournalRunOptions( + variables=variables, dry_run=dry_run, run_id=run_id, device_id=device_id, session_id=session_id)) diff --git a/je_auto_control/utils/action_journal/commands.py b/je_auto_control/utils/action_journal/commands.py index bec43fd93..f2a59d33d 100644 --- a/je_auto_control/utils/action_journal/commands.py +++ b/je_auto_control/utils/action_journal/commands.py @@ -1,5 +1,6 @@ """Executor adapters for explicit nested journal capture.""" from typing import Optional +from pathlib import Path # pylint: disable=unused-import # reason: runtime resolution of the forward path annotation from je_auto_control.utils.action_journal.capture import JournalRunResult, journal_scope from je_auto_control.utils.action_journal.store import ActionJournal @@ -20,3 +21,10 @@ def _capture_body(*, body: list[object], path: str, run_id: Optional[str] = None record: dict[str, object] = dict(runner.execute_action(body, _validated=True)) return {"run_id": binding.run_id, "journal_path": str(journal.path), "record": record, "failed_actions": recorded_failures() - before} + + +def _read_journal(path: "str | Path", *, run_id: Optional[str] = None, limit: int = 100) -> list[dict[str, object]]: + """Return bounded sanitized snapshots under the enclosing journal roots.""" + # pylint: disable-next=import-outside-toplevel # reason: the reader imports capture helpers lazily + from je_auto_control.utils.action_journal.inspection import inspect_journal + return inspect_journal(path, run_id=run_id, limit=limit) diff --git a/je_auto_control/utils/action_journal/inspection.py b/je_auto_control/utils/action_journal/inspection.py index 51ac9c977..dc266932a 100644 --- a/je_auto_control/utils/action_journal/inspection.py +++ b/je_auto_control/utils/action_journal/inspection.py @@ -2,23 +2,33 @@ from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Callable, Optional from je_auto_control.utils.action_journal.events import ActionJournalError from je_auto_control.utils.action_journal.redaction import sanitize_event from je_auto_control.utils.action_journal.store import read_events from je_auto_control.utils.path_guard.path_guard import validate_path +from je_auto_control.utils.path_guard.policy import PathPolicy -def inspect_journal(path: str | Path, *, run_id: Optional[str] = None, limit: int = 100) -> list[dict[str, object]]: +def inspect_journal(path: str | Path, *, run_id: Optional[str] = None, limit: int = 100, + observer: Optional[Callable[[int], None]] = None, + path_policy: Optional[PathPolicy] = None) -> list[dict[str, object]]: """Validate a whole journal, then return at most ``limit`` sanitized snapshots. Filtering precedes the 1–10,000 output limit; validation still loads the whole file. Start/completion snapshots stay separate, and missing files return an empty list. The source is never modified. The default path-root guard applies, unlike the low-level ``read_events`` storage reader. + Enclosing captured roots and an explicit policy intersect. The observer + sees every validated record count before filtering and may cancel reading. """ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 10000: raise ActionJournalError("journal limit must be an integer from 1 to 10000") - events = read_events(validate_path(path), run_id=run_id) + # pylint: disable-next=import-outside-toplevel # reason: package initialization imports this reader before capture + from je_auto_control.utils.action_journal.capture import _effective_path_policies + candidate = validate_path(path) + for policy in _effective_path_policies(path_policy): + candidate = policy.validate(candidate, operation="journal inspection") + events = read_events(candidate, run_id=run_id, observer=observer) return [sanitize_event(event) for event in events[:limit]] diff --git a/je_auto_control/utils/action_journal/run.py b/je_auto_control/utils/action_journal/run.py new file mode 100644 index 000000000..c042f88be --- /dev/null +++ b/je_auto_control/utils/action_journal/run.py @@ -0,0 +1,55 @@ +"""Isolated journal runs with typed controls and per-run metadata.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Mapping, Optional, Sequence + +from je_auto_control.utils.action_journal.capture import JournalRunResult, _failure_count, journal_scope +from je_auto_control.utils.action_journal.events import EventStatus +from je_auto_control.utils.action_journal.store import ActionJournal +from je_auto_control.utils.logging.secret_scope import secret_log_scope +from je_auto_control.utils.path_guard.path_guard import validate_path +from je_auto_control.utils.path_guard.policy import PathPolicy +from je_auto_control.utils.script_vars.execution import execution_scope + + +@dataclass(frozen=True) +class JournalRunOptions: + """Seeds, preview mode and inherited controls for one isolated run. + + The variable mapping is copied when the run starts. Observers can raise to cancel at + action boundaries and may run on joined worker threads. Nested captures + retain the controls; an explicit policy can only add restrictions. + """ + + variables: Optional[Mapping[str, object]] = None + dry_run: bool = False + run_id: Optional[str] = None + device_id: Optional[str] = None + session_id: Optional[str] = None + observer: Optional[Callable[[EventStatus], None]] = None + path_policy: Optional[PathPolicy] = None + + +@secret_log_scope() +def execute_journal_run(actions: Sequence[object] | Mapping[str, object], path: str | Path, *, + options: Optional[JournalRunOptions] = None) -> JournalRunResult: + """Run with fresh variables, durable capture and optional cooperative controls. + + The result matches ``execute_action_with_journal``. Capture opens no history + row/database implicitly; an existing owner receives the provenance link. + Native calls already in progress are not forcibly interrupted. + """ + # pylint: disable-next=import-outside-toplevel # reason: the executor imports capture boundaries + from je_auto_control.utils.executor.action_executor import executor + settings = options if options is not None else JournalRunOptions() + journal = ActionJournal(validate_path(path)) + with execution_scope(settings.variables), journal_scope( + journal, run_id=settings.run_id, device_id=settings.device_id, session_id=settings.session_id, + observer=settings.observer, path_policy=settings.path_policy) as binding: + before = _failure_count() + payload = dict(actions) if isinstance(actions, Mapping) else list(actions) + record: dict[str, object] = dict(executor.execute_action(payload, dry_run=settings.dry_run)) + return {"run_id": binding.run_id, "journal_path": str(journal.path), "record": record, + "failed_actions": _failure_count() - before} diff --git a/je_auto_control/utils/action_journal/store.py b/je_auto_control/utils/action_journal/store.py index 54a5a3f28..c228c46e7 100644 --- a/je_auto_control/utils/action_journal/store.py +++ b/je_auto_control/utils/action_journal/store.py @@ -3,8 +3,9 @@ import json import os +from contextlib import closing from pathlib import Path -from typing import BinaryIO, Iterator, Optional +from typing import BinaryIO, Callable, Generator, Iterator, Optional from je_auto_control.utils.action_journal.events import ActionEvent, ActionJournalError from je_auto_control.utils.action_journal.redaction import sanitize_event @@ -122,19 +123,34 @@ def _decode(raw: bytes, line_number: int) -> ActionEvent: raise ActionJournalError(f"invalid action journal record at line {line_number}") from None -def read_events(path: Path, *, run_id: Optional[str] = None) -> list[ActionEvent]: +def read_events(path: Path, *, run_id: Optional[str] = None, + observer: Optional[Callable[[int], None]] = None) -> list[ActionEvent]: """Read validated snapshots in append order, optionally selecting one run. A final unterminated fragment after a valid record is uncommitted and ignored. Ambiguous first fragments, malformed complete lines, unsupported schemas and non-increasing sequences are rejected. Start snapshots remain incomplete; reading never infers successful completion. + An observer receives the validated record count before filtering, and may + raise to cancel. The handle closes before its original exception propagates. """ + selected = [] + with closing(_iter_events(path)) as events: + for count, event in enumerate(events, 1): + if observer is not None: + observer(count) + if run_id is None or event.run_id == run_id: + selected.append(event) + return selected + + +def _iter_events(path: Path) -> Generator[ActionEvent, None, None]: + """Own the handle and translate storage failures without wrapping observers.""" try: with Path(path).open("rb") as handle: - return [event for event in _snapshots(handle) if run_id is None or event.run_id == run_id] + yield from _snapshots(handle) except FileNotFoundError: - return [] + return except (OSError, TypeError, ValueError) as error: if isinstance(error, ActionJournalError): raise diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 07d97ec3c..a45f61b96 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -34,8 +34,7 @@ ) from je_auto_control.utils.executor.action_redaction import describe_action, redact_actions from je_auto_control.utils.action_journal.capture import action_step -from je_auto_control.utils.action_journal.commands import _capture_body -from je_auto_control.utils.action_journal.inspection import inspect_journal +from je_auto_control.utils.action_journal.commands import _capture_body, _read_journal from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS @@ -7496,7 +7495,7 @@ def __init__(self): "AC_check_catalog": _check_catalog, "AC_run_resumable": _run_resumable, "AC_journal_capture": _capture_body, - "AC_journal_read": inspect_journal, + "AC_journal_read": _read_journal, "AC_checkpoint_status": _checkpoint_status, "AC_checkpoint_clear": _checkpoint_clear, "AC_mark_screen": _mark_screen, diff --git a/je_auto_control/utils/mcp_server/__init__.py b/je_auto_control/utils/mcp_server/__init__.py index 27776f30d..517b8231d 100644 --- a/je_auto_control/utils/mcp_server/__init__.py +++ b/je_auto_control/utils/mcp_server/__init__.py @@ -31,14 +31,14 @@ default_resource_provider, ) from je_auto_control.utils.mcp_server.tools import ( - MCPContent, MCPTool, MCPToolAnnotations, build_default_tool_registry, + MCPContent, MCPTool, MCPToolAnnotations, MCPToolResult, build_default_tool_registry, make_plugin_tool, register_plugin_tools, ) __all__ = [ "AuditLogger", "FakeState", "HttpMCPServer", "LiveScreenProvider", "MCPContent", "MCPLogBridge", "MCPPrompt", "MCPPromptArgument", - "MCPResource", "MCPServer", "MCPTool", "MCPToolAnnotations", + "MCPResource", "MCPServer", "MCPTool", "MCPToolAnnotations", "MCPToolResult", "OperationCancelledError", "PluginWatcher", "PromptProvider", "RateLimiter", "ResourceProvider", "ToolCallContext", "build_default_tool_registry", diff --git a/je_auto_control/utils/mcp_server/context.py b/je_auto_control/utils/mcp_server/context.py index d1387ce9b..a2cc71715 100644 --- a/je_auto_control/utils/mcp_server/context.py +++ b/je_auto_control/utils/mcp_server/context.py @@ -9,6 +9,8 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional +from je_auto_control.utils.path_guard.policy import PathPolicy + class OperationCancelledError(RuntimeError): """Raised by :meth:`ToolCallContext.check_cancelled` when the client cancels.""" @@ -34,6 +36,7 @@ class ToolCallContext: default=None, repr=False, ) cancelled_event: threading.Event = field(default_factory=threading.Event) + path_policy: Optional[PathPolicy] = field(default=None, repr=False) @property def cancelled(self) -> bool: diff --git a/je_auto_control/utils/mcp_server/server.py b/je_auto_control/utils/mcp_server/server.py index aa7ede38c..19b4735f2 100644 --- a/je_auto_control/utils/mcp_server/server.py +++ b/je_auto_control/utils/mcp_server/server.py @@ -36,6 +36,7 @@ from je_auto_control.utils.mcp_server.tools import ( MCPTool, build_default_tool_registry, ) +from je_auto_control.utils.mcp_server.tools._base import _tool_result_error from je_auto_control.utils.mcp_server.tools._validation import ( undeclared_arguments, validate_arguments, ) @@ -684,13 +685,15 @@ def _handle_tools_call(self, msg_id: Any, finally: with self._calls_lock: self._active_calls.pop(call_key, None) + failure = _tool_result_error(result) self._audit.record( - tool=name, arguments=arguments, status="ok", + tool=name, arguments=arguments, status="error" if failure is not None else "ok", duration_seconds=time.monotonic() - started_at, + error_text=failure, ) response: Dict[str, Any] = { "content": _to_content_blocks(result), - "isError": False, + "isError": failure is not None, } # 2025-06-18 spec: tools with an outputSchema return their dict result # as structuredContent for typed, token-cheap client consumption. @@ -706,6 +709,7 @@ def _build_call_context(self, msg_id: Any, return ToolCallContext( request_id=msg_id, progress_token=progress_token, notifier=self._notifier, + path_policy=self.argument_policy.path_policy, ) diff --git a/je_auto_control/utils/mcp_server/tools/__init__.py b/je_auto_control/utils/mcp_server/tools/__init__.py index 49cdf5c59..9ce070969 100644 --- a/je_auto_control/utils/mcp_server/tools/__init__.py +++ b/je_auto_control/utils/mcp_server/tools/__init__.py @@ -10,7 +10,7 @@ from typing import Dict, List, Optional from je_auto_control.utils.mcp_server.tools._base import ( - MCPContent, MCPTool, MCPToolAnnotations, read_only_env_flag, + MCPContent, MCPTool, MCPToolAnnotations, MCPToolResult, read_only_env_flag, ) from je_auto_control.utils.mcp_server.tools._factories import ALL_FACTORIES from je_auto_control.utils.mcp_server.tools.plugin_tools import ( @@ -92,7 +92,7 @@ def build_default_tool_registry(read_only: Optional[bool] = None, __all__ = [ - "MCPContent", "MCPTool", "MCPToolAnnotations", + "MCPContent", "MCPTool", "MCPToolAnnotations", "MCPToolResult", "build_default_tool_registry", "make_plugin_tool", "register_plugin_tools", ] diff --git a/je_auto_control/utils/mcp_server/tools/_base.py b/je_auto_control/utils/mcp_server/tools/_base.py index 7bb26ed63..afafadab4 100644 --- a/je_auto_control/utils/mcp_server/tools/_base.py +++ b/je_auto_control/utils/mcp_server/tools/_base.py @@ -8,7 +8,7 @@ import os from dataclasses import dataclass from functools import lru_cache -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Mapping, Optional @dataclass(frozen=True) @@ -77,6 +77,35 @@ def to_dict(self) -> Dict[str, Any]: return annotations +class MCPToolResult(dict[str, object]): + """A JSON object with explicit tool-failure metadata outside its data fields.""" + + __slots__ = ("_is_error", "_error_text") + + def __init__(self, value: Mapping[str, object], *, is_error: bool = False, + error_text: Optional[str] = None) -> None: + super().__init__(value) + self._is_error = is_error + self._error_text = error_text + + @property + def is_error(self) -> bool: + """Whether the tool reports a controlled execution failure.""" + return self._is_error + + @property + def error_text(self) -> Optional[str]: + """Optional audit detail; it is not added to the JSON data object.""" + return self._error_text + + +def _tool_result_error(value: Any) -> Optional[str]: + """Only explicit result objects can set the protocol's error status.""" + if isinstance(value, MCPToolResult) and value.is_error: + return value.error_text or "tool reported an execution failure" + return None + + @dataclass(frozen=True) class MCPTool: """A single MCP tool — public name, schema, and Python callable.""" diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index e7df99603..101d46baa 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -16,6 +16,7 @@ from je_auto_control.utils.mcp_server.tools import _handlers_screen as h_screen from je_auto_control.utils.mcp_server.tools import _handlers_system as h_system from je_auto_control.utils.mcp_server.tools import _handlers_runs as h_runs +from je_auto_control.utils.mcp_server.tools import _handlers_journal as h_journal from je_auto_control.utils.mcp_server.tools import _handlers_scheduling as h_sched from je_auto_control.utils.mcp_server.tools import _handlers_remote as h_remote from je_auto_control.utils.mcp_server.tools._base import ( @@ -27,6 +28,47 @@ _JSON_DOCUMENT = {"type": ["object", "array"]} +def journal_tools() -> List[MCPTool]: + """Build capture and sensitive-read tools with explicit result schemas.""" + metadata = schema({name: {"type": "string", "minLength": 1} + for name in ("run_id", "device_id", "session_id")}) + metadata["additionalProperties"] = False + return [ + MCPTool( + name="ac_journal_capture", + description=("Capture actions with fresh variables and durable journal snapshots. " + "metadata may label run_id, device_id and session_id. Reports progress " + "and observes cancellation at action boundaries, including local workers. " + "Recorded executor failures set isError; inspect record for domain outcomes."), + input_schema=schema({ + "actions": {"type": "array", "items": {"type": "array"}, "minItems": 1}, + "path": {"type": "string", "format": "path"}, + "metadata": metadata, "variables": {"type": "object"}, "dry_run": {"type": "boolean"}, + }, required=["actions", "path"]), + handler=h_journal.journal_capture, + annotations=DESTRUCTIVE, + output_schema=schema({ + "run_id": {"type": "string"}, "journal_path": {"type": "string"}, + "record": {"type": "object"}, "failed_actions": {"type": "integer", "minimum": 0}, + }, required=["run_id", "journal_path", "record", "failed_actions"]), + ), + MCPTool( + name="ac_journal_read", + description=("Validate a whole journal and return bounded sanitized snapshots. " + "Filter by run_id; limit defaults to 100 (1-10000). Progress counts " + "scanned records, and cancellation is checked between records. " + "Private file reads require drive_input under RBAC."), + input_schema=schema({ + "path": {"type": "string", "format": "path"}, "run_id": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 10000}, + }, required=["path"]), + handler=h_journal.journal_read, + annotations=READ_ONLY, + output_schema=schema({"events": {"type": "array", "items": {"type": "object"}}}, required=["events"]), + ), + ] + + def mouse_tools() -> List[MCPTool]: return [ MCPTool( @@ -8995,6 +9037,7 @@ def media_assert_tools() -> List[MCPTool]: ALL_FACTORIES = ( + journal_tools, mouse_tools, keyboard_tools, screen_tools, image_and_ocr_tools, window_tools, system_tools, recording_tools, drag_and_send_tools, semantic_locator_tools, self_healing_tools, anchor_locator_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers_journal.py b/je_auto_control/utils/mcp_server/tools/_handlers_journal.py new file mode 100644 index 000000000..74de4a529 --- /dev/null +++ b/je_auto_control/utils/mcp_server/tools/_handlers_journal.py @@ -0,0 +1,75 @@ +"""MCP journal operations with durable progress and cooperative cancellation.""" +import threading +from typing import Any, Optional + +from je_auto_control.utils.mcp_server.context import ToolCallContext +from je_auto_control.utils.mcp_server.tools._base import MCPToolResult + + +class _CallProgress: + """Serialize completed-step notifications from joined local workers.""" + + def __init__(self, ctx: ToolCallContext) -> None: + self.ctx = ctx + self.value = 0 + self._lock = threading.Lock() + + def __call__(self, status: str) -> None: + self.ctx.check_cancelled() + if status == "incomplete": + return + with self._lock: + self.value += 1 + self.ctx.progress(self.value, message="Journal steps completed") + + def scanned(self, count: int) -> None: + """Check each record; coalesce read progress notifications in batches.""" + self.ctx.check_cancelled() + self.value = count + if count % 100 == 0: + self.ctx.progress(count, message="Journal snapshots validated") + + +def journal_capture(actions: list[Any], path: str, metadata: Optional[dict[str, str]] = None, + variables: Optional[dict[str, object]] = None, dry_run: bool = False, *, + ctx: Optional[ToolCallContext] = None) -> MCPToolResult: + """Capture an isolated run, retaining result metadata on recorded failures.""" + # pylint: disable-next=import-outside-toplevel # reason: keep journal business imports lazy in the tool registry + from je_auto_control.utils.action_journal.run import JournalRunOptions, execute_journal_run + progress = _start(ctx, "Capturing actions") + labels = metadata if metadata is not None else {} + result = execute_journal_run(actions, path, options=JournalRunOptions( + variables=variables, dry_run=dry_run, run_id=labels.get("run_id"), device_id=labels.get("device_id"), + session_id=labels.get("session_id"), observer=progress, + path_policy=ctx.path_policy if ctx is not None else None)) + _finished(progress, "Journal capture finished") + failures = result["failed_actions"] + return MCPToolResult(result, is_error=failures > 0, + error_text=f"{failures} action(s) failed" if failures else None) + + +def journal_read(path: str, run_id: Optional[str] = None, limit: int = 100, *, + ctx: Optional[ToolCallContext] = None) -> dict[str, object]: + """Inspect sanitized snapshots while allowing cancellation during validation.""" + # pylint: disable-next=import-outside-toplevel # reason: keep journal business imports lazy in the tool registry + from je_auto_control.utils.action_journal.inspection import inspect_journal + progress = _start(ctx, "Reading journal") + events = inspect_journal(path, run_id=run_id, limit=limit, + observer=progress.scanned if progress is not None else None, + path_policy=ctx.path_policy if ctx is not None else None) + _finished(progress, "Journal read finished") + return {"events": events} + + +def _start(ctx: Optional[ToolCallContext], message: str) -> Optional[_CallProgress]: + if ctx is None: + return None + ctx.check_cancelled() + ctx.progress(0, message=message) + return _CallProgress(ctx) + + +def _finished(progress: Optional[_CallProgress], message: str) -> None: + if progress is not None: + progress.ctx.check_cancelled() + progress.ctx.progress(progress.value, message=message) diff --git a/je_auto_control/utils/mcp_server/tools/_validation.py b/je_auto_control/utils/mcp_server/tools/_validation.py index 524a28fc2..ab2dbd315 100644 --- a/je_auto_control/utils/mcp_server/tools/_validation.py +++ b/je_auto_control/utils/mcp_server/tools/_validation.py @@ -2,7 +2,7 @@ The default tool registry only references a handful of schema features — ``type``, ``properties``, ``required``, ``items``, -``enum`` — so a 50-line validator is enough and avoids pulling in +``enum`` and explicit ``additionalProperties: false`` — so a small validator avoids pulling in ``jsonschema`` as a runtime dependency. The validator intentionally returns the first violation it finds rather than collecting them all, which keeps the JSON-RPC error message short. @@ -84,6 +84,8 @@ def _validate_object(schema: Dict[str, Any], value: Dict[str, Any], for name, child_value in value.items(): child_schema = properties.get(name) if child_schema is None: + if schema.get("additionalProperties") is False: + return f"{path}: unknown property {name!r}" continue error = _validate(child_schema, child_value, f"{path}.{name}") if error is not None: diff --git a/je_auto_control/utils/rbac/policy.py b/je_auto_control/utils/rbac/policy.py index 932e48e3f..568871074 100644 --- a/je_auto_control/utils/rbac/policy.py +++ b/je_auto_control/utils/rbac/policy.py @@ -120,6 +120,8 @@ #: MCP tools whose capability is not the one their read-only hint implies. TOOL_CAPABILITIES: Dict[str, str] = { + "ac_journal_capture": _DRIVE, + "ac_journal_read": _DRIVE, "ac_remote_host_start": _HOSTS, "ac_remote_host_stop": _HOSTS, "ac_usb_acl_add": _HOSTS, diff --git a/test/unit_test/headless/_contract_sweep.py b/test/unit_test/headless/_contract_sweep.py index f8308e844..f5b6068f7 100644 --- a/test/unit_test/headless/_contract_sweep.py +++ b/test/unit_test/headless/_contract_sweep.py @@ -234,6 +234,14 @@ def _module_picks_a_backend(module_path: str) -> bool: return False +def _typed_dict_value(cls: Any, seen: FrozenSet[type]) -> Dict[str, Any]: + """Populate required keys rather than treating a TypedDict as an empty dict.""" + if cls in seen: + raise ValueError(f"self-referential TypedDict field on {cls!r}") + return {name: _value_for(annotation, seen | {cls}) + for name, annotation in typing.get_type_hints(cls).items() if name in cls.__required_keys__} + + def _value_for(annotation: Any, seen: FrozenSet[type] = frozenset()) -> Any: """Build the emptiest value a return annotation allows. @@ -252,6 +260,8 @@ def _value_for(annotation: Any, seen: FrozenSet[type] = frozenset()) -> Any: return _value_for_generic(origin, typing.get_args(annotation), seen) if annotation in _ZEROS: return _ZEROS[annotation]() + if typing.is_typeddict(annotation): + return _typed_dict_value(annotation, seen) if isinstance(annotation, type) and dataclasses.is_dataclass(annotation): return _dataclass_value(annotation, seen) if isinstance(annotation, type): diff --git a/test/unit_test/headless/test_journal_observation.py b/test/unit_test/headless/test_journal_observation.py new file mode 100644 index 000000000..313176bc4 --- /dev/null +++ b/test/unit_test/headless/test_journal_observation.py @@ -0,0 +1,185 @@ +"""Journal controls survive nested roots, workers, cancellation and path changes.""" +import asyncio +import threading + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.action_journal.capture import current_journal +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.path_guard import PathNotAllowedError, PathPolicy + + +def test_options_keep_isolation_and_observe_durable_completions(tmp_path): + path = tmp_path / "events.jsonl" + statuses = [] + def observe(status): + if status != "incomplete": + assert ac.read_events(path)[-1].status == status + statuses.append(status) + variables = {"message": "before"} + options = ac.JournalRunOptions(variables=variables, run_id="observed", observer=observe) + result = ac.execute_journal_run([["AC_set_var", {"name": "message", "value": "after"}]], path, + options=options) + assert statuses == ["incomplete", "ok"] and result["run_id"] == "observed" + assert variables == {"message": "before"} and current_journal() is None + + +@pytest.mark.parametrize("worker", ["loop", "parallel", "dag", "capture"]) +def test_nested_steps_inherit_observers_and_policies(tmp_path, worker): + statuses = [] + lock = threading.Lock() + def observe(status): + with lock: + statuses.append(status) + inner = [["AC_set_var", {"name": "x", "value": 1}]] + if worker == "loop": + action = ["AC_loop", {"times": 1, "body": inner}] + elif worker == "parallel": + action = ["AC_parallel", {"branches": [inner]}] + elif worker == "dag": + action = ["AC_run_dag", {"definition": {"nodes": [{"id": "one", "actions": inner}]}}] + else: + action = ["AC_journal_capture", {"path": str(tmp_path / "nested.jsonl"), "body": inner}] + options = ac.JournalRunOptions(observer=observe, path_policy=PathPolicy([tmp_path])) + ac.execute_journal_run([action], tmp_path / "outer.jsonl", options=options) + assert statuses.count("incomplete") == statuses.count("ok") == 2 + assert current_journal() is None + + +def test_cancellation_before_dispatch_opens_no_file(tmp_path): + path = tmp_path / "cancelled.jsonl" + signal = asyncio.CancelledError("controlled cancellation") + def observe(_status): + raise signal + with pytest.raises(asyncio.CancelledError) as caught: + ac.execute_journal_run([["AC_set_var", {"name": "x", "value": 1}]], path, + options=ac.JournalRunOptions(observer=observe)) + assert caught.value is signal and not path.exists() and current_journal() is None + + +@pytest.mark.parametrize("signal", [ValueError("original"), KeyboardInterrupt(), asyncio.CancelledError()]) +def test_completion_observer_never_replaces_an_existing_failure(tmp_path, monkeypatch, signal): + def fail(): + raise signal + def observe(status): + if status != "incomplete": + raise RuntimeError("observer unavailable") + monkeypatch.setitem(executor.event_dict, "AC_observed_failure", fail) + with pytest.raises(type(signal)) as caught: + with ac.journal_scope(ac.ActionJournal(tmp_path / "failure.jsonl"), observer=observe): + executor.execute_action([["AC_observed_failure"]], raise_on_error=True) + assert caught.value is signal and current_journal() is None + + +def test_observer_failure_after_success_leaves_a_durable_success(tmp_path): + path = tmp_path / "completed.jsonl" + def observe(status): + if status == "ok": + raise RuntimeError("observer unavailable") + with pytest.raises(RuntimeError, match="observer unavailable"): + ac.execute_journal_run([["AC_set_var", {"name": "x", "value": 1}]], path, + options=ac.JournalRunOptions(observer=observe)) + assert [event.status for event in ac.read_events(path)] == ["incomplete", "ok"] + + +def test_nested_controls_compose_without_relaxing_or_duplicating(tmp_path): + allowed, outside = tmp_path / "allowed", tmp_path / "outside" + allowed.mkdir() + outside.mkdir() + statuses = [] + observer = statuses.append + policy = PathPolicy([allowed]) + with ac.journal_scope(ac.ActionJournal(allowed / "outer.jsonl"), observer=observer, path_policy=policy): + with ac.journal_scope(ac.ActionJournal(allowed / "inner.jsonl"), observer=observer, + path_policy=policy) as binding: + assert len(binding.observers) == len(binding.path_policies) == 1 + executor.execute_action([["AC_set_var", {"name": "x", "value": 1}]]) + with pytest.raises(PathNotAllowedError): + with ac.journal_scope(ac.ActionJournal(outside / "refused.jsonl"), path_policy=PathPolicy()): + pytest.fail("nested policy relaxed its enclosing roots") + with pytest.raises(PathNotAllowedError): + ac.inspect_journal(outside / "absent.jsonl", path_policy=PathPolicy()) + assert statuses == ["incomplete", "ok"] and list(outside.iterdir()) == [] + + +@pytest.mark.parametrize("worker", ["direct", "parallel", "dag"]) +def test_dynamic_nested_journal_paths_cannot_escape_captured_roots(tmp_path, worker): + allowed = tmp_path / "allowed" + allowed.mkdir() + outside = tmp_path / "outside.jsonl" + nested = [["AC_journal_capture", {"path": "${destination}", "body": [ + ["AC_set_var", {"name": "x", "value": 1}]]}]] + actions = nested if worker == "direct" else [["AC_parallel", {"branches": [nested]}]] if worker == "parallel" else [ + ["AC_run_dag", {"definition": {"nodes": [{"id": "one", "actions": nested}]}}]] + result = ac.execute_journal_run(actions, allowed / "outer.jsonl", options=ac.JournalRunOptions( + variables={"destination": str(outside)}, path_policy=PathPolicy([allowed]))) + assert not outside.exists() + if worker == "dag": + outcome = next(iter(result["record"].values())) + assert outcome["succeeded"] is False and outcome["nodes"]["one"]["status"] == "failed" + assert "PathNotAllowedError" in outcome["nodes"]["one"]["error"] + else: + assert result["failed_actions"] > 0 + + +def test_policy_revocation_stops_the_next_append(tmp_path): + allowed, other = tmp_path / "allowed", tmp_path / "other" + allowed.mkdir() + other.mkdir() + policy = PathPolicy(use_client_roots=True) + policy.set_client_roots([allowed]) + path = allowed / "events.jsonl" + def observe(status): + if status == "ok": + policy.set_client_roots([other]) + with pytest.raises(PathNotAllowedError): + ac.execute_journal_run([["AC_set_var", {"name": "x", "value": 1}]] * 2, path, + options=ac.JournalRunOptions(path_policy=policy, observer=observe)) + assert [event.status for event in ac.read_events(path)] == ["incomplete", "ok"] + + +def test_completion_policy_failure_preserves_a_pending_hard_signal(tmp_path, monkeypatch): + allowed, other = tmp_path / "allowed", tmp_path / "other" + allowed.mkdir() + other.mkdir() + policy = PathPolicy(use_client_roots=True) + policy.set_client_roots([allowed]) + signal = KeyboardInterrupt() + def fail(): + policy.set_client_roots([other]) + raise signal + monkeypatch.setitem(executor.event_dict, "AC_policy_interruption", fail) + path = allowed / "unfinished.jsonl" + with pytest.raises(KeyboardInterrupt) as caught: + ac.execute_journal_run([["AC_policy_interruption"]], path, options=ac.JournalRunOptions(path_policy=policy)) + assert caught.value is signal + assert [event.status for event in ac.read_events(path)] == ["incomplete"] + + +def test_cancelled_reader_closes_its_file_and_preserves_the_signal(tmp_path, monkeypatch): + from pathlib import Path + path = tmp_path / "read.jsonl" + ac.execute_action_with_journal([["AC_set_var", {"name": "x", "value": 1}]], path) + opened = [] + original = Path.open + def track(self, *args, **kwargs): + handle = original(self, *args, **kwargs) + opened.append(handle) + return handle + signal = asyncio.CancelledError("stop reading") + def observe(_count): + raise signal + monkeypatch.setattr(Path, "open", track) + with pytest.raises(asyncio.CancelledError) as caught: + ac.inspect_journal(path, observer=observe) + assert caught.value is signal and opened and all(handle.closed for handle in opened) + + +def test_reader_observes_all_validated_records_before_filtering(tmp_path): + path = tmp_path / "read.jsonl" + for run_id in ("one", "two"): + ac.execute_action_with_journal([["AC_set_var", {"name": "x", "value": 1}]], path, run_id=run_id) + seen = [] + payload = ac.inspect_journal(path, run_id="two", limit=1, observer=seen.append) + assert seen == [1, 2, 3, 4] and len(payload) == 1 and payload[0]["run_id"] == "two" diff --git a/test/unit_test/headless/test_mcp_journal.py b/test/unit_test/headless/test_mcp_journal.py new file mode 100644 index 000000000..718c6daea --- /dev/null +++ b/test/unit_test/headless/test_mcp_journal.py @@ -0,0 +1,200 @@ +"""Journal tools keep RPC errors, progress, permissions and roots explicit.""" +import io +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.mcp_server._argument_policy import ArgumentPolicy +from je_auto_control.utils.mcp_server.context import OperationCancelledError, ToolCallContext +from je_auto_control.utils.mcp_server.server import MCPServer +from je_auto_control.utils.mcp_server.tools import MCPTool, build_default_tool_registry +from je_auto_control.utils.mcp_server.tools._base import schema +from je_auto_control.utils.path_guard import PathPolicy +from je_auto_control.utils.rbac import AuthorizationContext, Role, authorization_scope + + +@pytest.fixture(autouse=True) +def no_native_error_capture(monkeypatch): + monkeypatch.setattr("je_auto_control.utils.mcp_server.server._capture_error_screenshot", lambda _name: None) + + +def _tool(name): + return next(tool for tool in build_default_tool_registry(read_only=False, aliases=False) if tool.name == name) + + +def _rpc(server, name, arguments): + return json.loads(server.handle_line(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": arguments}}))) + + +def _data(reply): + return json.loads(reply["result"]["content"][0]["text"]) + + +def test_tools_expose_capture_and_sensitive_read_hints(): + capture, reader = _tool("ac_journal_capture"), _tool("ac_journal_read") + assert capture.annotations.destructive and not capture.annotations.read_only + assert reader.annotations.read_only and reader.annotations.idempotent + readonly = {tool.name for tool in build_default_tool_registry(read_only=True, aliases=False)} + assert reader.name in readonly and capture.name not in readonly + for tool in (capture, reader): + assert tool.input_schema["properties"]["path"]["format"] == "path" + + +def test_rpc_capture_read_and_metadata_round_trip(tmp_path): + path = tmp_path / "rpc.jsonl" + server = MCPServer(tools=[_tool("ac_journal_capture"), _tool("ac_journal_read")]) + captured = _rpc(server, "ac_journal_capture", {"path": str(path), "variables": {"message": "hello"}, + "metadata": {"run_id": "rpc", "device_id": "device", "session_id": "session"}, + "actions": [["AC_get_var", {"name": "message"}]]}) + assert captured["result"]["isError"] is False + result = _data(captured) + assert result["run_id"] == "rpc" and result["failed_actions"] == 0 + assert "hello" in result["record"].values() + reply = _rpc(server, "ac_journal_read", {"path": str(path), "run_id": "rpc", "limit": 1}) + assert reply["result"]["isError"] is False + events = _data(reply)["events"] + assert len(events) == 1 and events[0]["status"] == "incomplete" + assert events[0]["device_id"] == "device" and events[0]["session_id"] == "session" + + +def test_recorded_failures_are_tool_errors_with_structured_run_metadata(tmp_path, monkeypatch): + def fail(): + raise ValueError("controlled failure") + monkeypatch.setitem(ac.executor.event_dict, "AC_mcp_journal_failure", fail) + path = tmp_path / "failed.jsonl" + server = MCPServer(tools=[_tool("ac_journal_capture")]) + reply = _rpc(server, "ac_journal_capture", {"path": str(path), "actions": [["AC_mcp_journal_failure"]]}) + assert reply["result"]["isError"] is True + result = reply["result"]["structuredContent"] + assert result["failed_actions"] == 1 and result["journal_path"] == str(path.resolve()) + assert result["run_id"] and _data(reply) == result + assert [event.status for event in ac.read_events(path)] == ["incomplete", "error"] + + +@pytest.mark.parametrize("name", ["ac_journal_capture", "ac_journal_read"]) +def test_viewers_cannot_list_or_call_private_journal_operations(tmp_path, name): + server = MCPServer(tools=[_tool(name)]) + args = {"path": str(tmp_path / "refused.jsonl")} + if name.endswith("capture"): + args["actions"] = [["AC_set_var", {"name": "x", "value": 1}]] + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + listed = json.loads(server.handle_line('{"jsonrpc":"2.0","id":2,"method":"tools/list"}')) + reply = _rpc(server, name, args) + assert listed["result"]["tools"] == [] and "error" in reply + assert not (tmp_path / "refused.jsonl").exists() + + +@pytest.mark.parametrize("name", ["ac_journal_capture", "ac_journal_read"]) +def test_server_roots_apply_before_journal_io(tmp_path, name): + allowed = tmp_path / "allowed" + allowed.mkdir() + server = MCPServer(tools=[_tool(name)]) + server.argument_policy = ArgumentPolicy(PathPolicy([allowed])) + args = {"path": str(tmp_path / "outside.jsonl")} + if name.endswith("capture"): + args["actions"] = [["AC_set_var", {"name": "x", "value": 1}]] + assert _rpc(server, name, args)["result"]["isError"] is True + assert not (tmp_path / "outside.jsonl").exists() + + +def test_server_passes_its_policy_to_dynamic_nested_captures(tmp_path): + allowed = tmp_path / "allowed" + allowed.mkdir() + server = MCPServer(tools=[_tool("ac_journal_capture")]) + server.argument_policy = ArgumentPolicy(PathPolicy([allowed])) + outside = tmp_path / "outside.jsonl" + reply = _rpc(server, "ac_journal_capture", {"path": str(allowed / "outer.jsonl"), + "variables": {"destination": str(outside)}, "actions": [["AC_journal_capture", { + "path": "${destination}", "body": [["AC_set_var", {"name": "x", "value": 1}]]}]]}) + assert reply["result"]["isError"] is True and not outside.exists() + assert _data(reply)["failed_actions"] == 1 + + +def test_progress_and_cancellation_reach_nested_loop_steps(tmp_path): + updates = [] + ctx = ToolCallContext(17, progress_token="capture") + def notify(_method, params): + updates.append(params["progress"]) + if params["progress"] >= 1: + ctx.cancelled_event.set() + ctx.notifier = notify + path = tmp_path / "cancelled.jsonl" + with pytest.raises(OperationCancelledError): + _tool("ac_journal_capture").invoke({"path": str(path), "actions": [["AC_loop", {"times": 3, + "body": [["AC_set_var", {"name": "x", "value": 1}]]}]]}, ctx=ctx) + assert updates == [0, 1] + assert [event.status for event in ac.read_events(path)] == ["incomplete", "incomplete", "ok", "cancelled"] + + +def test_cancellation_before_capture_has_no_file_effect(tmp_path): + path = tmp_path / "unused.jsonl" + ctx = ToolCallContext(18) + ctx.cancelled_event.set() + with pytest.raises(OperationCancelledError): + _tool("ac_journal_capture").invoke({"path": str(path), "actions": [["AC_screen_size"]]}, ctx=ctx) + assert not path.exists() + + +def test_read_cancellation_keeps_the_source_unchanged(tmp_path, monkeypatch): + path = tmp_path / "read.jsonl" + ac.execute_action_with_journal([["AC_set_var", {"name": "x", "value": 1}]] * 2, path) + before = path.read_bytes() + ctx = ToolCallContext(19) + check = ctx.check_cancelled + calls = [] + def cancel(): + calls.append(True) + if len(calls) == 3: + ctx.cancelled_event.set() + check() + monkeypatch.setattr(ctx, "check_cancelled", cancel) + with pytest.raises(OperationCancelledError): + _tool("ac_journal_read").invoke({"path": str(path)}, ctx=ctx) + assert path.read_bytes() == before + + +@pytest.mark.parametrize("extra", [{"metadata": {"unknown": "value"}}, {"limit": 0}, {"unexpected": 1}]) +def test_invalid_fields_are_rejected_before_effects(tmp_path, extra): + name = "ac_journal_capture" if "metadata" in extra else "ac_journal_read" + path = tmp_path / "invalid.jsonl" + args = {"path": str(path), **extra} + if name.endswith("capture"): + args["actions"] = [["AC_set_var", {"name": "x", "value": 1}]] + reply = _rpc(MCPServer(tools=[_tool(name)]), name, args) + assert reply["result"]["isError"] is True and not path.exists() + + +def test_explicit_object_results_signal_errors_without_changing_json_data(): + result = ac.MCPToolResult({"value": 3}, is_error=True, error_text="controlled failure") + assert json.loads(json.dumps(result)) == {"value": 3} + tool = MCPTool(name="controlled", description="controlled result", input_schema=schema({}), handler=lambda: result) + reply = _rpc(MCPServer(tools=[tool]), "controlled", {}) + assert reply["result"]["isError"] is True and _data(reply) == {"value": 3} + plain = MCPTool(name="ordinary", description="ordinary data", input_schema=schema({}), + handler=lambda: {"is_error": True}) + assert _rpc(MCPServer(tools=[plain]), "ordinary", {})["result"]["isError"] is False + + +def test_operator_cannot_hide_administration_inside_capture(tmp_path, monkeypatch): + effects = [] + monkeypatch.setitem(ac.executor.event_dict, "AC_secret_init", lambda **_kwargs: effects.append(True)) + path = tmp_path / "denied.jsonl" + server = MCPServer(tools=[_tool("ac_journal_capture")]) + with authorization_scope(AuthorizationContext("operator", Role.OPERATOR)): + reply = _rpc(server, "ac_journal_capture", {"path": str(path), "actions": [["AC_secret_init", {}]]}) + assert "error" in reply and not effects and not path.exists() + + +def test_stdio_capture_uses_the_same_typed_journal_result(tmp_path): + path = tmp_path / "stdio.jsonl" + request = {"jsonrpc": "2.0", "id": 44, "method": "tools/call", "params": { + "name": "ac_journal_capture", "arguments": {"path": str(path), "metadata": {"run_id": "stdio"}, + "actions": [["AC_set_var", {"name": "x", "value": 1}]]}}} + output = io.StringIO() + server = MCPServer(tools=[_tool("ac_journal_capture")]) + server.serve_stdio(stdin=io.StringIO(json.dumps(request) + "\n"), stdout=output) + reply = next(json.loads(line) for line in output.getvalue().splitlines() if json.loads(line).get("id") == 44) + assert reply["result"]["isError"] is False and reply["result"]["structuredContent"]["run_id"] == "stdio" + assert [event.status for event in ac.read_events(path)] == ["incomplete", "ok"] diff --git a/test/unit_test/headless/test_typed_dict_contract_samples.py b/test/unit_test/headless/test_typed_dict_contract_samples.py new file mode 100644 index 000000000..bdf780d5a --- /dev/null +++ b/test/unit_test/headless/test_typed_dict_contract_samples.py @@ -0,0 +1,33 @@ +"""Contract samples must include the keys promised by TypedDict returns.""" +from typing import TypedDict + +import pytest + +from headless._contract_sweep import _value_for +from je_auto_control.utils.action_journal.capture import JournalRunResult + + +class _OptionalPart(TypedDict, total=False): + optional_label: str + + +class _RequiredPart(_OptionalPart): + count: int + + +class _Recursive(TypedDict): + child: "_Recursive" + + +def test_real_journal_contract_includes_all_guaranteed_fields(): + assert _value_for(JournalRunResult) == { + "run_id": "", "journal_path": "", "record": {}, "failed_actions": 0} + + +def test_inherited_optional_fields_are_not_promised(): + assert _value_for(_RequiredPart) == {"count": 0} + + +def test_required_cycles_are_refused_without_fabricating_a_value(): + with pytest.raises(ValueError, match="self-referential"): + _value_for(_Recursive) From 103b831a5a535d061b552581cf11be1e6d1a36d1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 02:53:26 +0800 Subject: [PATCH 022/294] Add cancellable journal browsing to Run History --- CHANGELOG.md | 8 + Progress.md | 2 +- README.md | 7 +- README/README_zh-CN.md | 7 +- README/README_zh-TW.md | 7 +- architecture.md | 7 + architecture_explore.md | 18 +- docs/API_LIFECYCLE.md | 3 +- docs/CAPABILITY_MATRIX.md | 3 +- .../Eng/doc/new_features/new_features_doc.rst | 36 +- .../Zh/doc/new_features/new_features_doc.rst | 29 +- docs/updates/2026-10.md | 9 + docs/updates/README.md | 3 +- examples/README.md | 7 + je_auto_control/gui/journal_browser.py | 221 ++++++++++ je_auto_control/gui/journal_browser_worker.py | 84 ++++ .../gui/language_wrapper/english.py | 25 ++ .../gui/language_wrapper/japanese.py | 25 ++ .../language_wrapper/simplified_chinese.py | 25 ++ .../language_wrapper/traditional_chinese.py | 25 ++ je_auto_control/gui/run_history_tab.py | 37 +- .../headless/test_journal_browser_gui.py | 394 ++++++++++++++++++ 22 files changed, 955 insertions(+), 27 deletions(-) create mode 100644 je_auto_control/gui/journal_browser.py create mode 100644 je_auto_control/gui/journal_browser_worker.py create mode 100644 test/unit_test/headless/test_journal_browser_gui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b728adfb..1082a35d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Run History includes read-only journal browsing from an existing provenance + link or an explicit file. Run filtering and bounded sanitized lifecycle + snapshots share the headless inspector. Reads run off the GUI thread, report + progress and support cooperative cancellation; closing the view requests + cancellation and stale results cannot overwrite a changed selection. + Deferred readers refresh stored owners before I/O and retain enclosing path + restrictions. Operations live in Actions and labels support all four languages. + - MCP `ac_journal_capture` and `ac_journal_read` support structured journal results, progress and cooperative cancellation. Recorded action failures set `isError` while retaining the run metadata. `MCPToolResult` lets handlers mark diff --git a/Progress.md b/Progress.md index f919c66f3..76f7e6b01 100644 --- a/Progress.md +++ b/Progress.md @@ -14,7 +14,7 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的專用 GUI 介面、手動執行的 run-history 接線與日誌查閱、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的手動執行 run-history 接線、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 diff --git a/README.md b/README.md index e20db34c5..0100953f5 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,8 @@ desktop app; tab commands live in the window's **Actions** menu. | USB enumeration & passthrough | `list_usb_devices`, `enable_usb_passthrough` | `AC_usb_*` (16 commands) | USB Devices, USB Share | | Secrets vault | `default_secret_manager` | `AC_secret_set` + `${secrets.NAME}` | Secrets | | Reports (HTML / JSON / XML) | `generate_html_report` | `AC_generate_html_report` | Report | -| Run history | — | — | Run History | +| Run history | `HistoryStore` | `AC_history_list` | Run History | +| Action journals | `execute_journal_run`, `inspect_journal` | `AC_journal_capture`, `AC_journal_read` | Run History, Script Builder | | Metrics & tracing | `default_metric_registry`, `render_metrics_text` | — | — | | Diagnostics | `run_diagnostics` | `AC_diagnose` | Diagnostics | | Test-code generation | `generate_code` | — | — | @@ -258,7 +259,9 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. --- -Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). `AC_journal_capture` accepts named parameters and runs a nested `body` on the owning executor without replacing its variables; `AC_journal_read` shares sanitized, bounded inspection with the CLI and public `inspect_journal`. Both commands are editable in Script Builder. Capture rejects identifiers or destinations containing secrets known to the run before history linking, file creation or action effects. MCP `ac_journal_capture` and `ac_journal_read` provide structured results, progress and cooperative cancellation. Captured journal roots and observers survive nested captures and local workers; reads can cancel between validated records. `JournalRunOptions` and `execute_journal_run` expose the same controls in Python. Dedicated GUI browsing and manual-run history controls remain pending integration. +Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). `AC_journal_capture` accepts named parameters and runs a nested `body` on the owning executor without replacing its variables; `AC_journal_read` shares sanitized, bounded inspection with the CLI and public `inspect_journal`. Both commands are editable in Script Builder. Capture rejects identifiers or destinations containing secrets known to the run before history linking, file creation or action effects. MCP `ac_journal_capture` and `ac_journal_read` provide structured results, progress and cooperative cancellation. Captured journal roots and observers survive nested captures and local workers; reads can cancel between validated records. `JournalRunOptions` and `execute_journal_run` expose the same controls in Python. Manual-run history controls remain pending integration. + +Run History includes a read-only journal browser. Use **Actions → Use Journals from Selected Run** to select a provenance link, or **Choose Journal** for an external file. Set a run ID and snapshot limit, then **Read Journal**; start/completion snapshots stay separate and their full sanitized data appears on selection. **Cancel Journal Read** and closing the view request cancellation between records. File parsing runs in a background worker; selecting a link opens no file. Values resolved from `${secrets.NAME}` are masked in application diagnostics during executor runs, including nested actions and local parallel/DAG workers. CLI/REST/MCP requests and scheduled, trigger, hotkey and email runs retain the mask through error handling and audit/history writes; report records capture redacted snapshots. DAG calls retain the owner mask through complete node diagnostics, including standalone calls and custom local runners. Watchdog, observer and callback-executor calls also retain it through final diagnostics; independent rules have separate masks. CLI stderr is masked, while API outcomes, raised exceptions and CLI result payloads retain their original values. Masks are discarded when the owning request or run exits. Interrupted history runs are recorded as `error`, while journal steps remain `cancelled`; no error screenshot is taken. Cancellation propagates through non-strict execution, recovery/retry, callbacks and local workers. Hard exits retain their original signal even if history completion fails. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 4a7cdf0c5..ef5a1789d 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -235,7 +235,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | USB 枚举与直通 | `list_usb_devices`、`enable_usb_passthrough` | `AC_usb_*`(16 个命令) | USB Devices、USB Share | | 密钥保险库 | `default_secret_manager` | `AC_secret_set` + `${secrets.NAME}` | Secrets | | 报表(HTML/JSON/XML) | `generate_html_report` | `AC_generate_html_report` | Report | -| 运行历史 | — | — | Run History | +| 运行历史 | `HistoryStore` | `AC_history_list` | Run History | +| 动作日志 | `execute_journal_run`、`inspect_journal` | `AC_journal_capture`、`AC_journal_read` | Run History、Script Builder | | 指标与追踪 | `default_metric_registry`、`render_metrics_text` | — | — | | 系统诊断 | `run_diagnostics` | `AC_diagnose` | Diagnostics | | 测试代码生成 | `generate_code` | — | — | @@ -245,7 +246,9 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。`AC_journal_capture` 接受命名参数,在拥有者 executor 上执行嵌套 `body`,保留原变量作用域;`AC_journal_read` 与 CLI、公开的 `inspect_journal` 共用遮罩后的有界查询。两个命令都可在 Script Builder 编辑。捕获会在 history 链接、创建文件及执行动作前拒绝含有当次执行已知密钥值的标识符或目标路径。MCP 的 `ac_journal_capture` 与 `ac_journal_read` 提供结构化结果、进度与协作式取消。捕获的日志根目录限制与 observer 会保留到嵌套捕获及本机 workers;查询可在逐条验证时取消。Python 可通过 `JournalRunOptions` 与 `execute_journal_run` 使用相同控制。专用 GUI 查询与手动执行的 history 控制仍待接入。 +实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。`AC_journal_capture` 接受命名参数,在拥有者 executor 上执行嵌套 `body`,保留原变量作用域;`AC_journal_read` 与 CLI、公开的 `inspect_journal` 共用遮罩后的有界查询。两个命令都可在 Script Builder 编辑。捕获会在 history 链接、创建文件及执行动作前拒绝含有当次执行已知密钥值的标识符或目标路径。MCP 的 `ac_journal_capture` 与 `ac_journal_read` 提供结构化结果、进度与协作式取消。捕获的日志根目录限制与 observer 会保留到嵌套捕获及本机 workers;查询可在逐条验证时取消。Python 可通过 `JournalRunOptions` 与 `execute_journal_run` 使用相同控制。手动执行的 history 控制仍待接入。 + +Run History 提供只读日志查看。使用 **操作 → 使用所选执行的日志** 选择来源关联,或用 **选择日志** 指定外部文件;设置执行 ID 与快照上限后,使用 **读取日志**。开始/完成快照保持分离,选中后会显示完整的遮罩数据。**取消读取日志** 与关闭视图会在逐条边界请求取消;文件解析在后台 worker 执行,仅选择关联不会打开文件。 `${secrets.NAME}` 解析后的值会在 executor 执行期间的应用程序诊断中遮罩,包括嵌套动作及本机 parallel/DAG。CLI/REST/MCP 请求与排程、trigger、hotkey、email 执行会将遮罩保留到错误处理及 audit/history 写入完成;报表记录保存遮罩后的快照。DAG 会将拥有者的遮罩保留到 node 诊断完成,包括独立调用与自定义 local runner。watchdog、observer 与 callback executor 会将遮罩保留到最后的错误处理;独立规则彼此隔离。CLI stderr 会遮罩,API 结果、抛出的异常与 CLI 结果数据保留原始值。遮罩于拥有者的请求或执行结束时清除。中止的 history 记为 `error`,journal 步骤保持 `cancelled`,不再获取错误截图。取消会穿过非严格执行、恢复/重试、回调与本机 worker;history 结算失败也不会取代原始硬中止信号。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 65ac7b235..2ae465860 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -234,7 +234,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | USB 列舉與直通 | `list_usb_devices`、`enable_usb_passthrough` | `AC_usb_*`(16 個指令) | USB Devices、USB Share | | 機密保險庫 | `default_secret_manager` | `AC_secret_set` + `${secrets.NAME}` | Secrets | | 報表(HTML/JSON/XML) | `generate_html_report` | `AC_generate_html_report` | Report | -| 執行歷史 | — | — | Run History | +| 執行歷史 | `HistoryStore` | `AC_history_list` | Run History | +| 動作日誌 | `execute_journal_run`、`inspect_journal` | `AC_journal_capture`、`AC_journal_read` | Run History、Script Builder | | 指標與追蹤 | `default_metric_registry`、`render_metrics_text` | — | — | | 系統診斷 | `run_diagnostics` | `AC_diagnose` | Diagnostics | | 測試碼產生 | `generate_code` | — | — | @@ -244,7 +245,9 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。`AC_journal_capture` 接受具名參數,在擁有者 executor 上執行巢狀 `body`,保留原變數範圍;`AC_journal_read` 與 CLI、公開的 `inspect_journal` 共用遮罩後的有界查閱。兩個指令皆可在 Script Builder 編輯。擷取會在 history 連結、開檔及動作執行前拒絕含有當次執行已知秘密的識別碼或目的地。MCP 的 `ac_journal_capture` 與 `ac_journal_read` 提供結構化結果、進度與合作式取消。擷取的日誌根目錄限制與 observer 會保留至巢狀擷取及本機 workers;查閱可在逐筆驗證時取消。Python 可透過 `JournalRunOptions` 與 `execute_journal_run` 使用相同控制。專用 GUI 查閱與手動執行的 history 控制仍待接入。 +實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。`AC_journal_capture` 接受具名參數,在擁有者 executor 上執行巢狀 `body`,保留原變數範圍;`AC_journal_read` 與 CLI、公開的 `inspect_journal` 共用遮罩後的有界查閱。兩個指令皆可在 Script Builder 編輯。擷取會在 history 連結、開檔及動作執行前拒絕含有當次執行已知秘密的識別碼或目的地。MCP 的 `ac_journal_capture` 與 `ac_journal_read` 提供結構化結果、進度與合作式取消。擷取的日誌根目錄限制與 observer 會保留至巢狀擷取及本機 workers;查閱可在逐筆驗證時取消。Python 可透過 `JournalRunOptions` 與 `execute_journal_run` 使用相同控制。手動執行的 history 控制仍待接入。 + +Run History 提供唯讀日誌檢視。使用 **操作 → 使用所選執行的日誌** 選擇來源連結,或用 **選擇日誌** 指定外部檔案;設定執行 ID 與快照上限後,使用 **讀取日誌**。起始/完成快照保持分離,選取後會顯示完整的遮罩資料。**取消讀取日誌** 與關閉檢視會在逐筆邊界提出取消要求;檔案解析在背景 worker 執行,單純選取連結不會開檔。 `${secrets.NAME}` 解析後的值會在 executor 執行期間的應用程式診斷中遮罩,包含巢狀動作及本機 parallel/DAG。CLI/REST/MCP 請求與排程、trigger、hotkey、email 執行會將遮罩保留到錯誤處理及 audit/history 寫入完成;報表紀錄保存遮罩後的快照。DAG 會將擁有者的遮罩保留到 node 診斷完成,包含獨立呼叫與自訂 local runner。watchdog、observer 與 callback executor 會將遮罩保留到最後的錯誤處理;獨立規則彼此隔離。CLI stderr 會遮罩,API 結果、拋出的例外與 CLI 結果資料保留原始值。遮罩於擁有者的請求或執行結束時清除。中止的 history 記為 `error`,journal 步驟維持 `cancelled`,不再擷取錯誤截圖。取消會穿過非嚴格執行、復原/重試、回呼與本機 worker;history 結算失敗也不會取代原始硬中止訊號。 diff --git a/architecture.md b/architecture.md index a299afa96..1260864cc 100644 --- a/architecture.md +++ b/architecture.md @@ -153,6 +153,13 @@ nested journal operations; general nested action argument policies remain pendin `MCPToolResult` keeps failure metadata outside its JSON dictionary fields. The server sets `isError` and audit status from this explicit type while preserving structured payloads; ordinary dictionaries cannot set protocol flags. +`gui/journal_browser.py` embeds read-only snapshot inspection in Run History. +Its worker copies the submission context, refreshes deferred authority before +file I/O and uses the shared inspector. Counts are queued to GUI slots; +request generations reject old results after a target change. Closing the view +or destroying its owner requests cooperative cancellation. The existing daemon +worker relay drops callbacks after owner destruction. Selecting history links +loads metadata only; neither inspection nor link selection creates a history row. `utils/run_history/context.py` binds an existing history row to execution. Scheduler/trigger/hotkey/webhook/email owners bind their own row; local parallel and DAG workers carry it explicitly. `journal_scope` attaches an idempotent diff --git a/architecture_explore.md b/architecture_explore.md index cc7609bd9..bda8b8c9b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,14 +19,14 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,091 | -| 程式碼總行數 | 163,483 | +| Python 模組總數(含周邊子專案) | 1,093 | +| 程式碼總行數 | 163,915 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 781 | | 套件門面 `__all__` 公開名稱數 | 1,277 | | GUI 分頁數(`main_widget` 註冊) | 48 | | MCP 工具數(`build_default_tool_registry()` 實測) | 682 | -| `test_*.py` 測試檔/測試函式 | 766 / 8,060 | +| `test_*.py` 測試檔/測試函式 | 767 / 8,083 | | 範例腳本 | 29 | **技術基線**:Python ≥ 3.10、MIT 授權、必要相依只有 `je_open_cv`/`opencv-python`/`pillow`/`mss`/ @@ -901,7 +901,9 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `_screen_geometry.py` | 55 | Qt 邏輯座標與截圖用的原生像素互轉:`native_region()`、`screen_at_native()`、`logical_point()`(每個螢幕的左上角在兩者相同,螢幕內依 device pixel ratio 縮放)。區域選取與主機端標註覆蓋層都用它。 | | `_daemon_thread.py` | 79 | `DaemonThread`:`QThread` 的替代品,保留遠端桌面 worker 用到的介面(`start`/`run`/`isRunning`/`wait`/`requestInterruption`/`started`/`finished`),但 `run()` 跑在 daemon `threading.Thread` 上,刪除物件或程式結束都不會銷毀執行中的執行緒。 | | `_worker_thread.py` | 216 | `start_worker()`:在 daemon `threading.Thread` 上執行 `QObject` worker 的 `run()`(沒有 `QThread` 可被銷毀),並經由分頁擁有的中繼物件回報結果(回呼一律在 GUI 執行緒;worker 沒處理的例外也送到 `on_fail`);worker 留在模組登錄表直到 GUI 執行緒看到它結束,回傳 `WorkerHandle`(`isRunning()`);程式結束時先呼叫 worker 的 `request_stop()`,最多等 10 秒,仍在跑的隨行程結束。 | -| `language_wrapper/` | 5,075 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | +| `journal_browser.py` | 221 | Run History 內的唯讀日誌檢視:來源連結/路徑/run 篩選、有界快照表與明細,舊結果以 request ID 隔離。 | +| `journal_browser_worker.py` | 84 | 背景查閱與逐筆取消;帶入 context 並在開檔前重查 deferred 擁有者,以 queued counts 回報進度。 | +| `language_wrapper/` | 5,175 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | | `selector/` | 216 | 拖曳選取螢幕區域的半透明全螢幕覆蓋層與樣板裁切工具(互動式,但都有對應的程式化 API)。 | > **分頁指令一律走 Actions 選單**:分頁本身只放輸入、表格與結果檢視,指令由視窗層選單暴露。 @@ -941,7 +943,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | a11y_audit | `a11y_audit_tab.py` | 114 | 從即時樹找出無障礙/i18n 缺陷。 | | device_matrix | `device_matrix_tab.py` | 111 | 一份 action list 跨多裝置平行執行。 | | media_checks | `media_checks_tab.py` | 116 | 音訊活動與影片動態斷言。 | -| run_history | `run_history_tab.py` + `run_history_timeline.py` | 462 | 瀏覽過去的排程/觸發/熱鍵執行,含自訂時間軸元件。 | +| run_history | `run_history_tab.py` + `run_history_timeline.py` | 462 | 瀏覽已記錄的執行、時間軸、截圖與非擁有式日誌連結;內嵌背景快照查閱。 | | profiler | `profiler_tab.py` | 131 | 視覺化逐動作耗時熱點。 | | window_manager | `window_tab.py` | 128 | 列出、聚焦、關閉視窗。 | | plugins | `plugins_tab.py` | 83 | 從使用者目錄載入額外 `AC_` 指令。 | @@ -1001,7 +1003,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `scripts/dev_release.py` | 只用標準函式庫的發佈輔助腳本 | dev 通道(PyPI 的 `je_auto_control_dev`)的版本與比對:`prepare` 把 `dev.toml` 寫成 `pyproject.toml`,版本取 PyPI 最新一版加一個 patch(`dev.toml` 的版本只是下限,不回寫 repo);`changed dist` 把建好的 wheel 跟 PyPI 最新一版逐檔比對,內容沒變就不上傳。只給 `dev.yml` 的 `publish-dev` job 與拋棄式的 checkout 用,因為 `prepare` 會蓋掉 `pyproject.toml`。 | | `docs/` | Sphinx(`API`/`Eng`/`Zh`/`getting_started`) | Read the Docs 文件。 | | `architecture_diagram/` | drawio + png | 既有的架構圖原始檔。 | -| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 766 個 `test_*.py`/8,060 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | +| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 767 個 `test_*.py`/8,083 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | --- @@ -1083,7 +1085,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 100 | 28,807 | +| `gui/` | 102 | 29,239 | | `utils/mcp_server/` | 38 | 19,323 | | `utils/remote_desktop/` | 56 | 13,301 | | `utils/executor/` | 8 | 9,704 | @@ -1104,5 +1106,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 700 | 59,877 | -| **總計** | **1,085** | **163,418** | +| **總計** | **1,087** | **163,850** | diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index 6fa3f4400..ec282582a 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -25,7 +25,8 @@ expanding the existing seven-parameter convenience signature. Nested scopes inherit observers and intersect policies; callbacks may run on joined worker threads. `MCPToolResult` is an experimental JSON-compatible object result with separate controlled-failure metadata. The MCP journal tools remain experimental -alongside the other B1 journal APIs. +alongside the other B1 journal APIs. Run History inspection uses these same +experimental snapshots; manual-run history ownership remains pending. - Stable API removal requires a deprecation warning and two minor releases. - Beta API removal requires one release note and one minor release. diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index 5cd869203..2bdde2ce1 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -26,7 +26,8 @@ Hardware-backed results and known limitations should be attached to releases. Journal storage, executor capture, provenance links, facade/CLI entry points and Script Builder commands, observed runs and MCP journal tools have controlled tests. The journal work does not -claim native device acceptance. Dedicated browsing and manual history ownership remain pending B1 integration. +claim native device acceptance. Run History journal browsing also has controlled offscreen tests. +Manual history ownership remains pending B1 integration. Linux Wayland is split: **capture is exercised by CI against a real compositor; input is exercised by CI against a real EI peer and a real portal.** diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index 1ef992851..bec31aac8 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1480,8 +1480,8 @@ Structured action journals (partial integration) B1 exports storage and explicit capture/read APIs from ``je_auto_control`` and ``je_auto_control.utils.action_journal``. CLI capture and bounded snapshot reads are available, along with action commands, Script Builder forms and dedicated -MCP capture/read tools. Dedicated GUI browsing and manual-run history controls -remain pending integration. The journal/provenance APIs remain experimental during B1. +MCP capture/read tools and read-only inspection in Run History. Manual-run +history controls remain pending integration. The journal/provenance APIs remain experimental during B1. Applications can explicitly write schema-v1 snapshots:: import time @@ -1640,6 +1640,38 @@ interrupted. ``examples/29_mcp_journal.py`` demonstrates the JSON-RPC workflow in process without desktop input or an external service. +Run History journal browser +----------------------------- + +Open **Run History**, select a history row and use **Actions > Use Journals +from Selected Run**. The linked-journal selector fills the file and run ID; +it does not open the file. Alternatively use **Actions > Choose Journal** +for any explicit JSONL source. A blank run ID selects all runs. Set the +snapshot limit (default 100, range 1–10,000), then **Actions > Read Journal**. +The whole file is validated before bounded sanitized snapshots appear. + +The table retains separate start/completion snapshots and shows sequence, +status, command, run/step/parent IDs. Select a snapshot to see its full JSON, +including device/session and artifact provenance. Reads do not execute journal +contents, create history rows or modify the source file. + +Parsing runs off the GUI thread through the existing worker relay. Progress +counts validated snapshots, coalesced every 100 records. **Actions > Cancel +Journal Read** and closing the history view request cancellation between +records; an in-progress decode is not forcibly interrupted. A cancelled or +failed read leaves no partial result visible. Choosing another link invalidates +and cancels the older request, so its queued result cannot replace the new +selection. Destroyed views receive no late callbacks. + +File selection, provenance lookup and inspection require ``drive_input`` when +an RBAC context is active. Before background I/O, stored owners are refreshed +so removal or demotion is honored. Workers retain the submitted context's +journal path restrictions as well as the default root guard. A rejected or +corrupt source is shown as an error, never as an empty successful read. +The controls, headers and state labels support all four GUI languages. +Manual execution's history ownership remains tracked in ``Progress.md``. + + History provenance links ------------------------ diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index 96bad2006..f24be149c 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1375,8 +1375,8 @@ GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher B1 的儲存與明確擷取/查閱 API 已從 ``je_auto_control`` 匯出,也可透過 ``je_auto_control.utils.action_journal`` 使用。CLI 擷取與有界快照查閱已接入; -動作指令、Script Builder 表單與專用 MCP 擷取/查閱工具也已接入;專用 GUI 查閱與手動 -執行的 history 控制仍待接入。B1 整合期間,日誌與來源連結 API +動作指令、Script Builder 表單、專用 MCP 擷取/查閱工具與 Run History 的唯讀檢視也已 +接入;手動執行的 history 控制仍待接入。B1 整合期間,日誌與來源連結 API 仍屬實驗性介面。應用程式可明確寫入 schema-v1 快照:: import time @@ -1505,6 +1505,31 @@ MCP context 會將伺服器的路徑政策帶入日誌操作,包含巢狀日 ``examples/29_mcp_journal.py`` 以同一行程示範 JSON-RPC 流程,不送出桌面輸入或呼叫外部服務。 +Run History 日誌檢視 +------------------------ + +開啟 **Run History**,選取歷史列,使用 **操作 > 使用所選執行的日誌**。 +已連結日誌選單會帶入檔案與執行 ID,但不會開檔;也可透過 **操作 > 選擇日誌** +指定外部 JSONL 檔。執行 ID 空白時選取全部執行。設定快照上限(預設 100、範圍 +1–10,000)後,使用 **操作 > 讀取日誌**。整份檔案驗證通過後才顯示有界、遮罩後的快照。 + +表格分別保留起始/完成快照,呈現順序、狀態、指令及 run/step/parent ID; +選取後可查看完整 JSON,包含 device/session 與 artifact 來源。 +查閱不會執行日誌內容、建立 history 列或修改來源檔案。 + +解析透過既有 worker relay 在背景執行,進度每 100 筆已驗證快照彙整回報。 +**操作 > 取消讀取日誌** 與關閉歷史檢視會在逐筆邊界提出取消要求,不會強制中斷 +正在進行的解碼。取消或失敗時不顯示部分結果。選取其他連結會取消舊請求並使其失效, +因此排隊中的舊結果無法覆蓋新目標;已銷毀的檢視不會收到延遲回呼。 + +RBAC context 啟用時,選檔、來源連結查詢與查閱都要求 ``drive_input``。 +背景 I/O 前會重新查詢儲存的擁有者,使用者遭刪除或降權時會拒絕讀取。 +Worker 保留提交 context 的 journal 路徑限制,也遵守預設根目錄政策。 +遭拒或損壞的來源會顯示錯誤,不會假裝成功讀到空結果。 +控制項、表頭與狀態文字支援 GUI 的四種語言。 +手動執行的 history 擁有者接線仍追蹤於 ``Progress.md``。 + + 執行歷史的日誌來源連結 ------------------------ diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index b5e55d4e8..5b74754fa 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -489,3 +489,12 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Validation**: the final full suite passes 11,848 tests with 69 skipped and 87.82% coverage. The final focused regression/map/budget selection passes 74 tests. 38 new cases pass, including 18 pre-implementation observation failures and the original 15 missing-MCP-tool cases. The adapter/registry/stub selection passes 1,863 tests with six skipped; 73 documentation, budget, complexity, exception, SQLite and light-import gates pass. Five journal/handler modules pass strict mypy, and whole-package three-platform typing reports zero errors. Ruff and bandit pass; new runner/handler pylint scores 10.00/10 under the project limits, and full-package radon reports no function above complexity B. Full-library pylint also ran and reported style/Qt-import diagnostics (7.88/10 with default settings); this is not a clean global lint gate. Nitpicky English/Chinese Sphinx and isolated wheel/source builds pass; all 18 changed production Python/stub files match both archives and the sdist README matches the source. The variable-only JSON-RPC example produces two expected snapshots. Jeffrey_RPA facade/control suites pass 740 tests against this source tree (five existing Discord deprecations). - **Files and docs**: journal runner/capture/inspection/storage, MCP context/result/handlers/registry/validation, RBAC, facade exports and regenerated action stub, adapter contract samples, regression tests, example 29, three READMEs, lifecycle/capability matrix, bilingual journal/MCP guides, changelog and measured architecture maps (1,277 facade names, 682 MCP names including 663 canonical tools and 19 aliases). - **Open items**: B1 dedicated GUI, manual-run history controls and journal browsing remain in `Progress.md`, along with general MCP nested-action argument-policy integration and the wider modernization plan. Native-device and paid-API acceptance remain deferred at the user's request. + +## U-20261019-05 · 2026-10-19 · Browse journal snapshots and history links in Run History · #done #journal #gui + +- **What**: Run History embeds a read-only journal browser with provenance-link selection or an explicit file, optional run filtering, a 1–10,000 snapshot limit, separate lifecycle rows and full sanitized JSON details. Selecting links loads metadata only. Existing headless inspection, CLI/commands and MCP contracts remain unchanged; browsing creates no history rows and does not mutate or execute the source. +- **Execution boundary**: a dedicated QObject worker copies the submission context, refreshes stored deferred owners before file I/O, retains inherited journal path policies and checks cancellation for each validated record. Count signals return to GUI slots in batches of 100. Closing or destroying the view requests cancellation; the existing worker relay drops callbacks to dead owners. A target change invalidates the previous request so queued results cannot replace the new selection. Cancellation and errors leave no partial snapshots visible. Lookup, file selection and inspection require drive_input under RBAC. +- **Validation**: the final full suite passes 11,872 tests with 69 skipped and 87.81% coverage. All 24 new offscreen cases pass. Initial collection failed for the absent browser; later controlled cases separately reproduced missing close-time cancellation and child retranslation before their corrections. A final controlled lookup-failure race also reproduced an older read overwriting the error state; failures now cancel and invalidate pending requests before presentation. Cases cover actual lifecycle snapshots/source preservation, run links/filtering/bounds, pre-cancel and record-boundary cancellation, stale results, owner destruction, GUI-thread delivery, inherited roots, viewer refusal before lookup/dialog/I/O, deleted/demoted stored users, corrupt input, active-mask error scrubbing and plain-text diagnostics. The history/worker/Actions-menu/documentation/budget/complexity selection passes 104 tests. Whole-package three-platform typing has zero errors; ruff and bandit pass, whole-package radon reports no function above complexity B, and both new modules score 10.00/10 in pylint with native Qt inspection and documented signal/control exceptions. Whole-library pylint also ran and reported style/Qt diagnostics (8.49/10); this is not a clean global lint gate. Nitpicky English/Chinese Sphinx and isolated wheel/source builds pass; all seven changed production sources and the sdist README match the archives. +- **Visual check**: inspected the rendered 1,280×900 history page and 540-pixel journal view. The offscreen Qt backend initially reported no fonts; loading the existing Segoe UI font in the probe allowed readable layout inspection without changing application fonts or invoking desktop input. +- **Files and docs**: journal browser/worker, Run History composition/lifecycle, four translation catalogues, offscreen regressions, three READMEs, journal Sphinx guides, lifecycle/capability references, example instructions, changelog and measured architecture maps. GUI tab/tool/command counts remain unchanged. +- **Open items**: B1 manual-run history ownership remains in Progress.md, along with B2/B3 and the broader GUI/worker modernization. Native-device and paid-API acceptance remain deferred at the user's request. diff --git a/docs/updates/README.md b/docs/updates/README.md index b29cb4729..cf4e257b7 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261019-05 | 2026-10-19 | Browse journal snapshots and history links in Run History | #done #journal #gui | [2026-10](2026-10.md) | | U-20261019-04 | 2026-10-19 | Add observed journal runs and dedicated MCP operations | #done #journal #mcp | [2026-10](2026-10.md) | | U-20261019-03 | 2026-10-19 | Add scoped journal commands and shared safe inspection | #done #journal #executor | [2026-10](2026-10.md) | | U-20261019-02 | 2026-10-19 | Expose journal capture and bounded CLI inspection | #done #journal #cli | [2026-10](2026-10.md) | @@ -386,7 +387,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 50 | +| [2026-10.md](2026-10.md) | 2026-10 | 51 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/examples/README.md b/examples/README.md index acfb3d6e6..560400827 100644 --- a/examples/README.md +++ b/examples/README.md @@ -69,3 +69,10 @@ python examples/01_screenshot_and_click.py A few scripts have optional dependencies — the script comments mention which `pip install` brings them in. + +### Inspect journals in the GUI + +Open Run History and use **Actions → Choose Journal** to select the JSONL file +produced by examples 28/29, then **Read Journal**. Existing history rows can +supply their provenance links through **Use Journals from Selected Run**. +Set a run ID and output limit, select snapshots for details, or cancel the read. diff --git a/je_auto_control/gui/journal_browser.py b/je_auto_control/gui/journal_browser.py new file mode 100644 index 000000000..721710586 --- /dev/null +++ b/je_auto_control/gui/journal_browser.py @@ -0,0 +1,221 @@ +"""Read-only lifecycle snapshots and provenance links inside Run History.""" +from __future__ import annotations + +import json +from typing import Callable, Optional, Sequence + +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QCloseEvent +from PySide6.QtWidgets import ( + QAbstractItemView, QComboBox, QFileDialog, QFormLayout, QLabel, QLineEdit, + QPlainTextEdit, QSpinBox, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui._worker_thread import WorkerHandle, start_worker +from je_auto_control.gui.journal_browser_worker import JournalReadOutcome, JournalReadRequest, JournalReadWorker +from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.logging.secret_scope import scrub_secret_text +from je_auto_control.utils.rbac.policy import authorize_command +from je_auto_control.utils.run_history.history_store import JournalLink + +_COLUMNS = ("sequence", "status", "command", "run_id", "step_id", "parent_id") + + +# pylint: disable-next=too-many-instance-attributes # reason: the view owns its child controls and one reader +class JournalBrowser(TranslatableMixin, QWidget): + """Inspect an explicit file or a selected history link without executing its contents.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._generation = 0 + self._handle: Optional[WorkerHandle] = None + self._worker: Optional[JournalReadWorker] = None + self._events: list[dict[str, object]] = [] + self._status_key, self._status_value = "journal_idle", "" + self._path, self._run_id = QLineEdit(), QLineEdit() + self._links = QComboBox() + self._links.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) + self._links.setMinimumContentsLength(16) + # pylint: disable-next=no-member # reason: PySide binds this Qt signal at runtime + self._links.currentIndexChanged.connect(self._select_link) + self._limit = QSpinBox() + self._limit.setRange(1, 10000) + self._limit.setValue(100) + self._table = QTableWidget(0, len(_COLUMNS)) + self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + # pylint: disable-next=no-member # reason: PySide binds this Qt signal at runtime + self._table.itemSelectionChanged.connect(self._show_details) + self._details = QPlainTextEdit() + self._details.setReadOnly(True) + self._status = QLabel() + self._status.setTextFormat(Qt.TextFormat.PlainText) + self._status.setWordWrap(True) + self._build_layout() + self.set_links([]) + self.retranslate() + + def _build_layout(self) -> None: + layout = QVBoxLayout(self) + layout.addWidget(self._tr(QLabel(), "journal_heading")) + form = QFormLayout() + form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) + for key, control in (("journal_links", self._links), ("journal_path", self._path), + ("journal_run", self._run_id), ("journal_limit", self._limit)): + form.addRow(self._tr(QLabel(), key), control) + layout.addLayout(form) + views = QSplitter(Qt.Orientation.Horizontal) + views.addWidget(self._table) + views.addWidget(self._details) + views.setStretchFactor(0, 2) + views.setStretchFactor(1, 1) + layout.addWidget(views, stretch=1) + layout.addWidget(self._status) + + def menu_actions(self) -> list[tuple[str, Callable[[], None]]]: + """Expose file selection, inspection and cancellation through the Actions menu.""" + return [("journal_browse", self.browse), ("journal_read", self.read), ("journal_cancel", self.cancel)] + + def retranslate(self) -> None: + """Refresh labels without losing the selected snapshot or current read state.""" + TranslatableMixin.retranslate(self) + self._table.setHorizontalHeaderLabels([ + language_wrapper.translate(f"journal_col_{column}") for column in _COLUMNS]) + if self._links.count(): + self._links.setItemText(0, language_wrapper.translate("journal_links_none")) + self._set_status(self._status_key, self._status_value) + + def _set_status(self, key: str, value: str = "") -> None: + self._status_key, self._status_value = key, value + self._status.setText(language_wrapper.translate(key).replace("{value}", value)) + + def _clear_events(self) -> None: + self._events = [] + self._table.setRowCount(0) + self._details.clear() + + def set_links(self, links: Sequence[JournalLink]) -> None: + """Select provenance metadata without opening a file; discard any older read result.""" + self.cancel() + self._generation += 1 + self._clear_events() + self._links.blockSignals(True) + self._links.clear() + self._links.addItem(language_wrapper.translate("journal_links_none"), None) + for link in links: + self._links.addItem(f"{link.journal_run_id} — {link.journal_path}", link) + self._links.setCurrentIndex(1 if links else 0) + self._links.blockSignals(False) + self._select_link(self._links.currentIndex()) + + def _select_link(self, index: int) -> None: + self.cancel() + self._generation += 1 + self._clear_events() + link = self._links.itemData(index) + self._path.setText(link.journal_path if isinstance(link, JournalLink) else "") + self._run_id.setText(link.journal_run_id if isinstance(link, JournalLink) else "") + self._set_status("journal_idle") + + def browse(self) -> None: + """Choose a source file without reading or rewriting it.""" + if self._handle is not None: + return + try: + authorize_command("AC_journal_read") + except AutoControlException as error: + self._failed(str(error)) + return + path, _ = QFileDialog.getOpenFileName(self, language_wrapper.translate("journal_browse"), "", + "JSONL (*.jsonl);;All files (*)") + if path: + self.set_links([]) + self._path.setText(path) + + def read(self) -> None: + """Launch bounded inspection; one active reader belongs to this widget.""" + if self._handle is not None: + return + self._clear_events() + try: + authorize_command("AC_journal_read") + except AutoControlException as error: + self._failed(str(error)) + return + path = self._path.text() + if not path.strip(): + self._set_status("journal_choose_path") + return + self._generation += 1 + request = JournalReadRequest(self._generation, path, self._run_id.text() or None, self._limit.value()) + worker = JournalReadWorker(request) + self._worker = worker + worker.progress.connect(self._progress) + # pylint: disable-next=no-member # reason: PySide binds this Qt signal at runtime + self.destroyed.connect(worker.request_stop) + self._set_busy(True) + self._set_status("journal_reading") + self._handle = start_worker(self, worker, on_done=self._received, + on_thread_done=self._thread_done, on_fail=self._failed) + + def cancel(self) -> None: + """Ask the current reader to stop at its next validated-record boundary.""" + if self._worker is not None: + self._worker.request_stop() + self._set_status("journal_cancelling") + + # pylint: disable-next=invalid-name # reason: Qt override + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 # reason: Qt override + """Request cancellation as soon as the view closes, before deferred deletion.""" + self.cancel() + super().closeEvent(event) + + def report_error(self, message: str) -> None: + """Display a failed provenance lookup without leaving old snapshots visible.""" + self._failed(message) + + def _set_busy(self, busy: bool) -> None: + for control in (self._path, self._run_id, self._limit, self._links): + control.setEnabled(not busy) + + @Slot(int, int) + def _progress(self, request_id: int, count: int) -> None: + if request_id == self._generation and self._status_key != "journal_cancelling": + self._set_status("journal_progress", str(count)) + + def _received(self, result: JournalReadOutcome) -> None: + if result.request_id != self._generation: + return + if result.status == "cancelled": + self._set_status("journal_cancelled") + elif result.status == "error": + self._failed(result.error) + else: + self._events = result.events + self._table.setRowCount(len(result.events)) + for row, event in enumerate(result.events): + for column, name in enumerate(_COLUMNS): + value = event.get(name) + self._table.setItem(row, column, QTableWidgetItem("" if value is None else str(value))) + self._set_status("journal_done", str(len(result.events))) + + def _failed(self, message: str) -> None: + self.cancel() + self._generation += 1 + self._clear_events() + self._set_status("journal_error", scrub_secret_text(message)) + + def _thread_done(self) -> None: + self._handle = self._worker = None + self._set_busy(False) + if self._status_key in ("journal_reading", "journal_progress", "journal_cancelling"): + self._set_status("journal_interrupted") + + def _show_details(self) -> None: + row = self._table.currentRow() + self._details.setPlainText(json.dumps(self._events[row], ensure_ascii=False, indent=2) + if 0 <= row < len(self._events) else "") diff --git a/je_auto_control/gui/journal_browser_worker.py b/je_auto_control/gui/journal_browser_worker.py new file mode 100644 index 000000000..665d2c186 --- /dev/null +++ b/je_auto_control/gui/journal_browser_worker.py @@ -0,0 +1,84 @@ +"""Cancellable journal inspection with captured context and refreshed authority.""" +from __future__ import annotations + +from asyncio import CancelledError as AsyncCancelledError +from concurrent.futures import CancelledError +from contextvars import copy_context +from dataclasses import dataclass, field +from threading import Event +from typing import Literal, Optional + +from PySide6.QtCore import QObject, Signal, Slot + +from je_auto_control.utils.action_journal.inspection import inspect_journal +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.logging.secret_scope import scrub_secret_text +from je_auto_control.utils.rbac.deferred import DeferredAuthorization + + +@dataclass(frozen=True) +class JournalReadRequest: + """One immutable target; its ID distinguishes late results from a new selection.""" + + request_id: int + path: str + run_id: Optional[str] + limit: int + + +@dataclass(frozen=True) +class JournalReadOutcome: + """A terminal read outcome; cancellation never supplies partial snapshots.""" + + request_id: int + status: Literal["ok", "cancelled", "error"] + events: list[dict[str, object]] = field(default_factory=list) + scanned: int = 0 + error: str = "" + + +class JournalReadWorker(QObject): + """Read off the GUI thread; callbacks carry only IDs, counts and sanitized data.""" + + finished = Signal(object) + progress = Signal(int, int) + + def __init__(self, request: JournalReadRequest) -> None: + super().__init__() + self._request = request + self._stop = Event() + self._context = copy_context() + self._authority = DeferredAuthorization.capture() + self._scanned = 0 + + @Slot() + def request_stop(self) -> None: + """Request cancellation without blocking the GUI or closing another thread's handle.""" + self._stop.set() + + @Slot() + def run(self) -> None: + """Emit exactly one terminal result for ordinary errors or cooperative cancellation.""" + self.finished.emit(self._context.run(self._inspect)) + + def _checkpoint(self, count: int) -> None: + if self._stop.is_set(): + raise CancelledError() + self._scanned = count + if count % 100 == 0: + self.progress.emit(self._request.request_id, count) + + def _inspect(self) -> JournalReadOutcome: + request = self._request + try: + self._checkpoint(0) + with self._authority.scope(): + events = inspect_journal(request.path, run_id=request.run_id, limit=request.limit, + observer=self._checkpoint) + self._checkpoint(self._scanned) + except (CancelledError, AsyncCancelledError): + return JournalReadOutcome(request.request_id, "cancelled", scanned=self._scanned) + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: + detail = scrub_secret_text(f"{type(error).__name__}: {error}") + return JournalReadOutcome(request.request_id, "error", scanned=self._scanned, error=detail) + return JournalReadOutcome(request.request_id, "ok", events=events, scanned=self._scanned) diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index cb3cdd285..6993bfaec 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -1340,4 +1340,29 @@ "menu_choose_script_dir": "Choose Script Directory...", "execute_editor_script": "Run Editor Content", "close_tab": "Close tab", + "journal_heading": "Action Journal", + "journal_links": "Linked journals", + "journal_links_none": "Manual file (no linked run)", + "journal_path": "Journal file", + "journal_run": "Run ID (blank = all)", + "journal_limit": "Snapshot limit", + "journal_browse": "Choose Journal...", + "journal_read": "Read Journal", + "journal_cancel": "Cancel Journal Read", + "journal_selected_run": "Use Journals from Selected Run", + "journal_idle": "Choose a journal, then use Actions > Read Journal.", + "journal_choose_path": "Choose a journal file first.", + "journal_reading": "Reading journal...", + "journal_progress": "Validated {value} snapshots...", + "journal_cancelling": "Cancelling journal read...", + "journal_cancelled": "Journal read cancelled.", + "journal_done": "Showing {value} snapshots.", + "journal_error": "Unable to read journal: {value}", + "journal_interrupted": "Journal read interrupted.", + "journal_col_sequence": "Sequence", + "journal_col_status": "Status", + "journal_col_command": "Command", + "journal_col_run_id": "Run ID", + "journal_col_step_id": "Step ID", + "journal_col_parent_id": "Parent ID", } diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 4dee87d8f..aee556539 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -1227,4 +1227,29 @@ "menu_choose_script_dir": "スクリプトフォルダーを選択...", "execute_editor_script": "エディター内容を実行", "close_tab": "タブを閉じる", + "journal_heading": "アクションログ", + "journal_links": "関連ログ", + "journal_links_none": "ファイルを指定(実行の関連付けなし)", + "journal_path": "ログファイル", + "journal_run": "実行 ID(空欄ですべて)", + "journal_limit": "表示件数の上限", + "journal_browse": "ログを選択...", + "journal_read": "ログを読み込む", + "journal_cancel": "ログの読み込みをキャンセル", + "journal_selected_run": "選択した実行のログを使用", + "journal_idle": "ログを選び、「操作 > ログを読み込む」を使用してください。", + "journal_choose_path": "ログファイルを選択してください。", + "journal_reading": "ログを読み込み中...", + "journal_progress": "{value} 件のスナップショットを検証済み...", + "journal_cancelling": "ログの読み込みをキャンセル中...", + "journal_cancelled": "ログの読み込みをキャンセルしました。", + "journal_done": "{value} 件のスナップショットを表示。", + "journal_error": "ログを読み込めません:{value}", + "journal_interrupted": "ログの読み込みが中断されました。", + "journal_col_sequence": "順序", + "journal_col_status": "状態", + "journal_col_command": "コマンド", + "journal_col_run_id": "実行 ID", + "journal_col_step_id": "ステップ ID", + "journal_col_parent_id": "親ステップ ID", } diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index ab8b422ba..c70a00ef9 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -1206,4 +1206,29 @@ "menu_choose_script_dir": "选择脚本目录...", "execute_editor_script": "运行编辑器内容", "close_tab": "关闭标签页", + "journal_heading": "动作日志", + "journal_links": "已关联日志", + "journal_links_none": "手动选文件(不限关联执行)", + "journal_path": "日志文件", + "journal_run": "执行 ID(空白为全部)", + "journal_limit": "快照上限", + "journal_browse": "选择日志...", + "journal_read": "读取日志", + "journal_cancel": "取消读取日志", + "journal_selected_run": "使用所选执行的日志", + "journal_idle": "选择日志后,使用“操作 > 读取日志”。", + "journal_choose_path": "请先选择日志文件。", + "journal_reading": "正在读取日志...", + "journal_progress": "已验证 {value} 条快照...", + "journal_cancelling": "正在取消读取日志...", + "journal_cancelled": "已取消读取日志。", + "journal_done": "显示 {value} 条快照。", + "journal_error": "无法读取日志:{value}", + "journal_interrupted": "日志读取已中止。", + "journal_col_sequence": "顺序", + "journal_col_status": "状态", + "journal_col_command": "命令", + "journal_col_run_id": "执行 ID", + "journal_col_step_id": "步骤 ID", + "journal_col_parent_id": "父步骤 ID", } diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index dfe41f4aa..5dd671bc1 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -1207,4 +1207,29 @@ "menu_choose_script_dir": "選擇腳本資料夾...", "execute_editor_script": "執行編輯器內容", "close_tab": "關閉分頁", + "journal_heading": "動作日誌", + "journal_links": "已連結日誌", + "journal_links_none": "手動選檔(不限定連結執行)", + "journal_path": "日誌檔案", + "journal_run": "執行 ID(空白為全部)", + "journal_limit": "快照上限", + "journal_browse": "選擇日誌...", + "journal_read": "讀取日誌", + "journal_cancel": "取消讀取日誌", + "journal_selected_run": "使用所選執行的日誌", + "journal_idle": "選擇日誌後,使用「操作 > 讀取日誌」。", + "journal_choose_path": "請先選擇日誌檔案。", + "journal_reading": "正在讀取日誌...", + "journal_progress": "已驗證 {value} 筆快照...", + "journal_cancelling": "正在取消讀取日誌...", + "journal_cancelled": "已取消讀取日誌。", + "journal_done": "顯示 {value} 筆快照。", + "journal_error": "無法讀取日誌:{value}", + "journal_interrupted": "日誌讀取已中止。", + "journal_col_sequence": "順序", + "journal_col_status": "狀態", + "journal_col_command": "指令", + "journal_col_run_id": "執行 ID", + "journal_col_step_id": "步驟 ID", + "journal_col_parent_id": "父步驟 ID", } diff --git a/je_auto_control/gui/run_history_tab.py b/je_auto_control/gui/run_history_tab.py index 4331892c5..dc925d062 100644 --- a/je_auto_control/gui/run_history_tab.py +++ b/je_auto_control/gui/run_history_tab.py @@ -1,10 +1,10 @@ -"""Run History tab: browse past scheduler / trigger / hotkey fires.""" +"""Run History tab: browse recorded runs, artifacts and explicit journal links.""" import datetime as _dt from pathlib import Path from typing import List, Optional from PySide6.QtCore import QTimer, Qt, QUrl -from PySide6.QtGui import QDesktopServices, QPixmap +from PySide6.QtGui import QCloseEvent, QDesktopServices, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QFrame, QHBoxLayout, QHeaderView, QLabel, QMessageBox, QSplitter, QTableWidget, QTableWidgetItem, @@ -16,6 +16,9 @@ language_wrapper, ) from je_auto_control.gui.run_history_timeline import RunHistoryTimeline +from je_auto_control.gui.journal_browser import JournalBrowser +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.rbac.policy import authorize_command from je_auto_control.utils.run_history.history_store import ( SOURCE_HOTKEY, SOURCE_MANUAL, SOURCE_REST, SOURCE_SCHEDULER, SOURCE_TRIGGER, STATUS_ERROR, STATUS_OK, STATUS_RUNNING, RunRecord, @@ -59,7 +62,7 @@ def _format_duration(seconds: Optional[float]) -> str: class RunHistoryTab(TranslatableMixin, QWidget): - """Timeline view of every fired scheduler job / trigger / hotkey.""" + """Timeline and read-only journal inspection for recorded execution owners.""" def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) @@ -92,6 +95,7 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._timer.setInterval(_REFRESH_INTERVAL_MS) self._timer.timeout.connect(self._refresh) self._auto_refresh = True + self._journal = JournalBrowser(self) self._build_layout() self._refresh() self._timer.start() @@ -101,6 +105,13 @@ def retranslate(self) -> None: self._apply_table_headers() self._repopulate_filter_labels() self._refresh() + self._journal.retranslate() + # pylint: disable-next=invalid-name # reason: Qt override + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 # reason: Qt override + """Stop journal reads immediately when the history page closes.""" + self._journal.cancel() + self._timer.stop() + super().closeEvent(event) def resizeEvent(self, event) -> None: super().resizeEvent(event) @@ -153,7 +164,12 @@ def _build_layout(self) -> None: splitter.addWidget(preview) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) - root.addWidget(splitter, stretch=1) + sections = QSplitter(Qt.Vertical) + sections.addWidget(splitter) + sections.addWidget(self._journal) + sections.setStretchFactor(0, 1) + sections.setStretchFactor(1, 1) + root.addWidget(sections, stretch=1) self._table.cellDoubleClicked.connect(self._on_cell_double_clicked) self._table.itemSelectionChanged.connect(self._on_selection_changed) @@ -165,7 +181,18 @@ def menu_actions(self) -> list: ("rh_refresh", self._refresh), ("rh_clear", self._on_clear), ("rh_open_artifact", self._open_selected_artifact), - ] + ("journal_selected_run", self._selected_journals), + ] + self._journal.menu_actions() + + def _selected_journals(self) -> None: + try: + authorize_command("AC_journal_read") + record = self._selected_record() + links = default_history_store.journals_for_run(record.id) if record is not None else [] + except (AutoControlException, OSError, ValueError) as error: + self._journal.report_error(str(error)) + return + self._journal.set_links(links) def _on_clear(self) -> None: reply = QMessageBox.question( diff --git a/test/unit_test/headless/test_journal_browser_gui.py b/test/unit_test/headless/test_journal_browser_gui.py new file mode 100644 index 000000000..15239fb07 --- /dev/null +++ b/test/unit_test/headless/test_journal_browser_gui.py @@ -0,0 +1,394 @@ +"""Offscreen journal browsing preserves snapshots, authority and owner lifetimes.""" +import threading +import time + +import pytest + +pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) +from PySide6.QtCore import QCoreApplication, QEvent +from PySide6.QtWidgets import QApplication, QPushButton + +import je_auto_control as ac +from je_auto_control.gui.journal_browser import JournalBrowser +from je_auto_control.gui.journal_browser_worker import JournalReadRequest, JournalReadWorker +from je_auto_control.utils.rbac import AuthorizationContext, Role, authorization_scope +from je_auto_control.utils.run_history.history_store import JournalLink + + +@pytest.fixture +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def browser(app): + widget = JournalBrowser() + yield widget + widget.cancel() + if widget._handle is not None: + widget._handle.wait(5) + widget.deleteLater() + app.processEvents() + + +def _until(app, predicate): + deadline = time.monotonic() + 5 + while not predicate() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.005) + assert predicate() + + +def _journal(path, run_id="chosen"): + ac.execute_journal_run([["AC_set_var", {"name": "x", "value": "safe"}]], path, + options=ac.JournalRunOptions(run_id=run_id)) + + +def test_browser_has_only_inputs_and_views_until_an_action(browser): + assert browser.findChildren(QPushButton) == [] + assert browser._handle is None and browser._table.rowCount() == 0 + assert browser._limit.minimum() == 1 and browser._limit.maximum() == 10000 + assert browser._limit.value() == 100 + + +def test_real_read_keeps_lifecycle_snapshots_and_source(browser, app, tmp_path): + path = tmp_path / "events.jsonl" + _journal(path) + original = path.read_bytes() + browser._path.setText(str(path)) + browser.read() + _until(app, lambda: browser._handle is None) + assert browser._table.rowCount() == 2 + assert [row["status"] for row in browser._events] == ["incomplete", "ok"] + browser._table.selectRow(1) + assert '"run_id": "chosen"' in browser._details.toPlainText() + assert path.read_bytes() == original + + +def test_links_select_run_without_automatic_file_io(browser, app, tmp_path): + path = tmp_path / "events.jsonl" + _journal(path, "one") + _journal(path, "two") + browser.set_links([JournalLink(1, str(path), "one"), JournalLink(1, str(path), "two")]) + assert browser._handle is None and browser._table.rowCount() == 0 + browser._links.setCurrentIndex(2) + assert browser._run_id.text() == "two" + browser._limit.setValue(1) + browser.read() + _until(app, lambda: browser._handle is None) + assert len(browser._events) == 1 and browser._events[0]["run_id"] == "two" + assert browser._events[0]["status"] == "incomplete" + + +def test_precancelled_worker_does_not_open_input(app, monkeypatch): + def forbidden(*args, **kwargs): + pytest.fail("cancelled worker opened a file") + monkeypatch.setattr("je_auto_control.gui.journal_browser_worker.inspect_journal", forbidden) + worker = JournalReadWorker(JournalReadRequest(1, "unused", None, 100)) + outcomes = [] + worker.finished.connect(outcomes.append) + worker.request_stop() + worker.run() + assert outcomes[0].status == "cancelled" and outcomes[0].events == [] + worker.deleteLater() + + +def test_worker_rechecks_captured_viewer_authority(app, monkeypatch): + calls = [] + monkeypatch.setattr("je_auto_control.gui.journal_browser_worker.inspect_journal", lambda *a, **k: calls.append(1)) + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + worker = JournalReadWorker(JournalReadRequest(1, "unused", None, 100)) + outcomes = [] + worker.finished.connect(outcomes.append) + worker.run() + assert outcomes[0].status == "error" and calls == [] + worker.deleteLater() + + +def test_worker_retains_enclosing_path_policy(app, tmp_path): + allowed = tmp_path / "allowed" + allowed.mkdir() + with ac.journal_scope(ac.ActionJournal(allowed / "outer.jsonl"), path_policy=ac.PathPolicy([allowed])): + worker = JournalReadWorker(JournalReadRequest(1, str(tmp_path / "outside.jsonl"), None, 100)) + outcomes = [] + worker.finished.connect(outcomes.append) + worker.run() + assert outcomes[0].status == "error" and "PathNotAllowedError" in outcomes[0].error + worker.deleteLater() + + +def test_worker_progress_cancels_between_records(app, monkeypatch): + scanned = [] + def inspect(path, **kwargs): + for count in range(1, 201): + scanned.append(count) + kwargs["observer"](count) + return [] + monkeypatch.setattr("je_auto_control.gui.journal_browser_worker.inspect_journal", inspect) + worker = JournalReadWorker(JournalReadRequest(1, "unused", None, 100)) + worker.progress.connect(lambda request, count: worker.request_stop() if count == 100 else None) + outcomes = [] + worker.finished.connect(outcomes.append) + worker.run() + assert outcomes[0].status == "cancelled" and max(scanned) <= 101 + worker.deleteLater() + + +def _blocked_reader(monkeypatch): + entered, release = threading.Event(), threading.Event() + def inspect(path, **kwargs): + entered.set() + assert release.wait(5) + kwargs["observer"](1) + return [{"sequence": 1, "run_id": "old", "status": "ok", "command": "AC_set_var"}] + monkeypatch.setattr("je_auto_control.gui.journal_browser_worker.inspect_journal", inspect) + return entered, release + + +def test_cancel_does_not_block_gui(browser, app, monkeypatch): + entered, release = _blocked_reader(monkeypatch) + browser._path.setText("unused") + browser.read() + try: + assert entered.wait(2) + browser.cancel() + assert browser._handle.isRunning() + finally: + release.set() + _until(app, lambda: browser._handle is None) + assert browser._events == [] and browser._status_key == "journal_cancelled" + + +def test_changing_target_discards_stale_completion(browser, app, monkeypatch): + entered, release = _blocked_reader(monkeypatch) + browser._path.setText("old") + browser.read() + try: + assert entered.wait(2) + browser.set_links([JournalLink(7, "new", "new-run")]) + finally: + release.set() + _until(app, lambda: browser._handle is None) + assert browser._path.text() == "new" and browser._run_id.text() == "new-run" + assert browser._events == [] and browser._status_key == "journal_idle" + + +def test_destroying_owner_requests_stop_and_drops_late_results(app, monkeypatch): + entered, release = _blocked_reader(monkeypatch) + widget = JournalBrowser() + widget._path.setText("unused") + widget.read() + handle, worker = widget._handle, widget._worker + try: + assert entered.wait(2) + widget.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + assert worker._stop.is_set() + finally: + release.set() + assert handle.wait(5) + app.processEvents() + + +def test_viewer_is_refused_before_worker_creation(browser): + browser._path.setText("private.jsonl") + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + browser.read() + assert browser._handle is None and browser._status_key == "journal_error" + + +def test_corrupt_read_clears_previous_snapshots(browser, app, tmp_path): + path = tmp_path / "events.jsonl" + _journal(path) + browser._path.setText(str(path)) + browser.read() + _until(app, lambda: browser._handle is None) + assert browser._events + path.write_text("invalid\n", encoding="utf-8") + browser.read() + _until(app, lambda: browser._handle is None) + assert browser._events == [] and browser._table.rowCount() == 0 + assert browser._status_key == "journal_error" + + +def test_history_menu_loads_links_without_reading(app, monkeypatch, tmp_path): + from je_auto_control.gui import run_history_tab as history_module + store = ac.HistoryStore(tmp_path / "history.sqlite") + run = store.start_run("manual", "test", "script") + store.attach_journal(run, str(tmp_path / "events.jsonl"), "chosen") + monkeypatch.setattr(history_module, "default_history_store", store) + tab = history_module.RunHistoryTab() + try: + tab._timer.stop() + tab._table.selectRow(0) + actions = dict(tab.menu_actions()) + assert "journal_selected_run" in actions and "journal_read" in actions and "journal_cancel" in actions + actions["journal_selected_run"]() + assert tab._journal._path.text() == str(tmp_path / "events.jsonl") + assert tab._journal._run_id.text() == "chosen" and tab._journal._handle is None + finally: + tab.deleteLater() + store.close() + + +def test_close_requests_cancellation_before_deferred_deletion(browser, app, monkeypatch): + entered, release = _blocked_reader(monkeypatch) + browser._path.setText("unused") + browser.read() + try: + assert entered.wait(2) + browser.close() + assert browser._worker._stop.is_set() + finally: + release.set() + _until(app, lambda: browser._handle is None) + assert browser._status_key == "journal_cancelled" + + +@pytest.mark.parametrize("change", ["remove", "demote"]) +def test_worker_refreshes_store_backed_owner_before_io(app, monkeypatch, tmp_path, change): + from je_auto_control.utils.rbac.authorization import resolve_token + from je_auto_control.utils.rbac.users import UserStore + store = UserStore(tmp_path / "users.json") + token = store.add_user(user_id="owner", display_name="Owner", role=Role.ADMIN) + with authorization_scope(resolve_token(store, token)): + worker = JournalReadWorker(JournalReadRequest(1, "private", None, 100)) + if change == "remove": + UserStore(store.path).remove_user("owner") + else: + UserStore(store.path).set_role("owner", Role.VIEWER) + def forbidden(*args, **kwargs): + pytest.fail("revoked owner read private data") + monkeypatch.setattr("je_auto_control.gui.journal_browser_worker.inspect_journal", forbidden) + outcomes = [] + worker.finished.connect(outcomes.append) + worker.run() + assert outcomes[0].status == "error" and outcomes[0].events == [] + worker.deleteLater() + + +def test_history_retranslates_browser_and_preserves_state(app, monkeypatch, tmp_path): + from je_auto_control.gui import run_history_tab as history_module + from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper + store = ac.HistoryStore(tmp_path / "history.sqlite") + monkeypatch.setattr(history_module, "default_history_store", store) + tab = history_module.RunHistoryTab() + old_language = language_wrapper.language + try: + tab._timer.stop() + tab._journal._set_status("journal_done", "2") + language_wrapper.reset_language("Japanese") + tab.retranslate() + assert tab._journal._status.text() == "2 件のスナップショットを表示。" + assert tab._journal._table.horizontalHeaderItem(1).text() == "状態" + finally: + language_wrapper.reset_language(old_language) + tab.deleteLater() + store.close() + + +def test_history_viewer_cannot_resolve_private_links(app, monkeypatch, tmp_path): + from je_auto_control.gui import run_history_tab as history_module + store = ac.HistoryStore(tmp_path / "history.sqlite") + store.start_run("manual", "test", "script") + monkeypatch.setattr(history_module, "default_history_store", store) + def forbidden(*args, **kwargs): + pytest.fail("viewer resolved private provenance") + monkeypatch.setattr(store, "journals_for_run", forbidden) + tab = history_module.RunHistoryTab() + try: + tab._timer.stop() + tab._table.selectRow(0) + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + dict(tab.menu_actions())["journal_selected_run"]() + assert tab._journal._status_key == "journal_error" + assert tab._journal._links.count() == 1 + finally: + tab.deleteLater() + store.close() + + +def test_browse_viewer_is_refused_before_file_dialog(browser, monkeypatch): + def forbidden(*args, **kwargs): + pytest.fail("viewer opened private file picker") + monkeypatch.setattr("je_auto_control.gui.journal_browser.QFileDialog.getOpenFileName", forbidden) + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + browser.browse() + assert browser._status_key == "journal_error" and browser._handle is None + + +def test_worker_masks_errors_in_active_captured_context(app, monkeypatch): + from je_auto_control.utils.logging.secret_scope import remember_secret, secret_log_scope + secret = "private-journal-marker" + def fail(*args, **kwargs): + raise ValueError(secret) + monkeypatch.setattr("je_auto_control.gui.journal_browser_worker.inspect_journal", fail) + outcomes = [] + with secret_log_scope(): + remember_secret(secret) + worker = JournalReadWorker(JournalReadRequest(1, "unused", None, 100)) + worker.finished.connect(outcomes.append) + worker.run() + assert outcomes[0].status == "error" and secret not in outcomes[0].error + assert "***" in outcomes[0].error + worker.deleteLater() + + +def test_error_details_are_plain_text(browser): + from PySide6.QtCore import Qt + browser.report_error("literal") + assert browser._status.textFormat() == Qt.TextFormat.PlainText + assert "literal" in browser._status.text() + + +def test_progress_is_delivered_on_gui_thread(browser, app, monkeypatch): + gui_ident = threading.get_ident() + calls = [] + original = browser._set_status + def status(key, value=""): + calls.append(threading.get_ident()) + original(key, value) + monkeypatch.setattr(browser, "_set_status", status) + def inspect(path, **kwargs): + kwargs["observer"](100) + return [] + monkeypatch.setattr("je_auto_control.gui.journal_browser_worker.inspect_journal", inspect) + browser._path.setText("unused") + browser.read() + _until(app, lambda: browser._handle is None) + assert calls and set(calls) == {gui_ident} + + +def test_history_close_cancels_its_child_reader(app, monkeypatch, tmp_path): + from je_auto_control.gui import run_history_tab as history_module + entered, release = _blocked_reader(monkeypatch) + store = ac.HistoryStore(tmp_path / "history.sqlite") + monkeypatch.setattr(history_module, "default_history_store", store) + tab = history_module.RunHistoryTab() + tab._journal._path.setText("unused") + tab._journal.read() + handle = tab._journal._handle + try: + assert entered.wait(2) + tab.close() + assert tab._journal._worker._stop.is_set() and not tab._timer.isActive() + finally: + release.set() + assert handle.wait(5) + app.processEvents() + tab.deleteLater() + store.close() + + +def test_lookup_error_invalidates_an_older_read(browser, app, monkeypatch): + entered, release = _blocked_reader(monkeypatch) + browser._path.setText("old") + browser.read() + try: + assert entered.wait(2) + browser.report_error("new history lookup failed") + finally: + release.set() + _until(app, lambda: browser._handle is None) + assert browser._events == [] and browser._status_key == "journal_error" + assert "new history lookup failed" in browser._status.text() From 57aa7564d4d00646bc63e0231a9a070c514cdd6c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 03:48:26 +0800 Subject: [PATCH 023/294] Add explicit manual run history across execution surfaces --- CHANGELOG.md | 10 + Progress.md | 2 +- README.md | 12 +- README/README_zh-CN.md | 12 +- README/README_zh-TW.md | 12 +- architecture.md | 8 + architecture_explore.md | 147 +++++------ docs/API_LIFECYCLE.md | 7 +- docs/CAPABILITY_MATRIX.md | 2 +- docs/source/Eng/doc/cli/cli_doc.rst | 20 +- .../Eng/doc/new_features/new_features_doc.rst | 67 ++++- docs/source/Zh/doc/cli/cli_doc.rst | 16 ++ .../Zh/doc/new_features/new_features_doc.rst | 55 ++++- docs/updates/2026-10.md | 10 + docs/updates/README.md | 3 +- examples/30_manual_history.py | 41 ++++ examples/README.md | 13 + je_auto_control/__init__.py | 2 + je_auto_control/actions.pyi | 3 + je_auto_control/cli.py | 29 ++- je_auto_control/gui/_script_tab.py | 15 +- .../gui/language_wrapper/english.py | 2 + .../gui/language_wrapper/japanese.py | 2 + .../language_wrapper/simplified_chinese.py | 2 + .../language_wrapper/traditional_chinese.py | 2 + je_auto_control/gui/main_widget.py | 20 +- je_auto_control/gui/manual_run_controls.py | 53 ++++ .../gui/script_builder/builder_tab.py | 11 +- .../gui/script_builder/command_schema.py | 7 + .../utils/executor/action_executor.py | 2 + .../utils/executor/action_schema.py | 1 + je_auto_control/utils/rbac/policy.py | 1 + je_auto_control/utils/run_history/__init__.py | 6 +- je_auto_control/utils/run_history/commands.py | 16 ++ je_auto_control/utils/run_history/context.py | 5 +- je_auto_control/utils/run_history/manual.py | 81 +++++++ .../utils/run_history/run_outcome.py | 10 +- test/unit_test/headless/test_doc_counts.py | 4 + .../headless/test_doc_line_counts.py | 50 +++- .../headless/test_gui_map_measurements.py | 39 +++ .../headless/test_journal_history_links.py | 8 +- .../unit_test/headless/test_manual_history.py | 228 ++++++++++++++++++ .../test_manual_history_entrypoints.py | 183 ++++++++++++++ .../headless/test_manual_history_gui.py | 146 +++++++++++ 44 files changed, 1237 insertions(+), 128 deletions(-) create mode 100644 examples/30_manual_history.py create mode 100644 je_auto_control/gui/manual_run_controls.py create mode 100644 je_auto_control/utils/run_history/commands.py create mode 100644 je_auto_control/utils/run_history/manual.py create mode 100644 test/unit_test/headless/test_gui_map_measurements.py create mode 100644 test/unit_test/headless/test_manual_history.py create mode 100644 test/unit_test/headless/test_manual_history_entrypoints.py create mode 100644 test/unit_test/headless/test_manual_history_gui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1082a35d1..8949439ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Explicit manual history is available through `manual_history_scope`, + `AC_history_capture`, CLI `run --history [--history-db PATH]`, and opt-in + controls in Script Executor / Script Builder. GUI and CLI results keep their + existing shape and variable scope. Ambient and nested journals link to the + manual row; failures and cancellation settle as error while preserving the + original signal. Normal loop control retains the counted outcome. CLI + previews carry a distinct source label. Known secret values are masked from + labels before insertion. A broken exception formatter falls back to its type + instead of replacing the original run failure. + - Run History includes read-only journal browsing from an existing provenance link or an explicit file. Run filtering and bounded sanitized lifecycle snapshots share the headless inspector. Reads run off the GUI thread, report diff --git a/Progress.md b/Progress.md index 76f7e6b01..a0be446d2 100644 --- a/Progress.md +++ b/Progress.md @@ -14,7 +14,7 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B1 的手動執行 run-history 接線、B2 自愈評估、B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B(動作日誌、自愈量測、codegen)仍缺 B2 自愈評估與 B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 diff --git a/README.md b/README.md index 0100953f5..882a05cba 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ from JSON files / CLI / servers, and a **GUI tab**. Nothing is GUI-only. - **One API, seven platforms.** `wrapper/platform_wrapper.py` picks the backend at import time; your script does not change between Windows, macOS, X11, and Wayland. -- **Scriptable without Python.** 781 `AC_*` commands cover the whole feature set, so a +- **Scriptable without Python.** 782 `AC_*` commands cover the whole feature set, so a JSON file can do anything the library can — including loops, branches, try/catch, macros, and variables. - **Headless by default.** `import je_auto_control` never loads Qt. The GUI is an @@ -233,7 +233,7 @@ desktop app; tab commands live in the window's **Actions** menu. | Natural-language planner | `plan_actions`, `run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`, `run_agent` | `AC_run_agent` | Computer Use | | Record & replay | `record`, `stop_record` | `AC_record`, `AC_stop_record` | Record | -| JSON scripting | `execute_action`, `execute_files` | all 781 commands | Script, Script Builder | +| JSON scripting | `execute_action`, `execute_files` | all 782 commands | Script, Script Builder | | Variables & flow control | `execute_action_with_vars` | `AC_set_var`, `AC_loop`, `AC_for_each`, `AC_try`, `AC_retry` | Variables | | Data-driven runs | — | `AC_for_each_row` (CSV / JSON / SQLite / Excel) | Data Sources | | Assertions | `assert_text`, `assert_image` | `AC_assert_text` + 20 more | Assertions | @@ -247,7 +247,7 @@ desktop app; tab commands live in the window's **Actions** menu. | USB enumeration & passthrough | `list_usb_devices`, `enable_usb_passthrough` | `AC_usb_*` (16 commands) | USB Devices, USB Share | | Secrets vault | `default_secret_manager` | `AC_secret_set` + `${secrets.NAME}` | Secrets | | Reports (HTML / JSON / XML) | `generate_html_report` | `AC_generate_html_report` | Report | -| Run history | `HistoryStore` | `AC_history_list` | Run History | +| Run history | `HistoryStore`, `manual_history_scope` | `AC_history_list`, `AC_history_capture` | Run History, Script Executor, Script Builder | | Action journals | `execute_journal_run`, `inspect_journal` | `AC_journal_capture`, `AC_journal_read` | Run History, Script Builder | | Metrics & tracing | `default_metric_registry`, `render_metrics_text` | — | — | | Diagnostics | `run_diagnostics` | `AC_diagnose` | Diagnostics | @@ -259,7 +259,9 @@ per-module map is in **[architecture_explore.md](architecture_explore.md)**. --- -Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). `AC_journal_capture` accepts named parameters and runs a nested `body` on the owning executor without replacing its variables; `AC_journal_read` shares sanitized, bounded inspection with the CLI and public `inspect_journal`. Both commands are editable in Script Builder. Capture rejects identifiers or destinations containing secrets known to the run before history linking, file creation or action effects. MCP `ac_journal_capture` and `ac_journal_read` provide structured results, progress and cooperative cancellation. Captured journal roots and observers survive nested captures and local workers; reads can cancel between validated records. `JournalRunOptions` and `execute_journal_run` expose the same controls in Python. Manual-run history controls remain pending integration. +Experimental structured journal capture is exported from `je_auto_control` (also available in `utils.action_journal`): `journal_scope` captures existing executor calls, and `execute_action_with_journal` isolates one run. Local loop/parallel/DAG steps retain parent IDs; failures, cancellation and dry-runs are distinct. Existing history owners automatically link explicit captures, including captures opened by local parallel/DAG workers; `HistoryStore.journals_for_run` returns the non-owning links. Clearing/pruning history leaves journal files intact. The CLI supports `run script.json --journal run.jsonl [--journal-run-id ID]` and `journal run.jsonl [--run-id ID] [--limit 100]`. Journal reads show sanitized lifecycle snapshots in append order; output defaults to 100 snapshots (maximum 10,000). `AC_journal_capture` accepts named parameters and runs a nested `body` on the owning executor without replacing its variables; `AC_journal_read` shares sanitized, bounded inspection with the CLI and public `inspect_journal`. Both commands are editable in Script Builder. Capture rejects identifiers or destinations containing secrets known to the run before history linking, file creation or action effects. MCP `ac_journal_capture` and `ac_journal_read` provide structured results, progress and cooperative cancellation. Captured journal roots and observers survive nested captures and local workers; reads can cancel between validated records. `JournalRunOptions` and `execute_journal_run` expose the same controls in Python. + +Manual runs can opt into history with `manual_history_scope`, `AC_history_capture`, CLI `run --history [--history-db PATH]`, or **Record in Run History** in Script Executor / Script Builder. An optional GUI journal path also works independently of history. GUI/CLI result JSON and existing variable scopes are preserved; the command returns `history_id`, `record` and `failed_actions`. Existing and nested captures link to the new manual row. CLI previews use the `cli.preview` source label; ordinary runs use `cli.run`. Run History includes a read-only journal browser. Use **Actions → Use Journals from Selected Run** to select a provenance link, or **Choose Journal** for an external file. Set a run ID and snapshot limit, then **Read Journal**; start/completion snapshots stay separate and their full sanitized data appears on selection. **Cancel Journal Read** and closing the view request cancellation between records. File parsing runs in a background worker; selecting a link opens no file. @@ -454,7 +456,7 @@ ignore synthetic input, and fall back silently when the driver is absent. | Resource | What's in it | |---|---| -| [`examples/`](examples/) | 29 self-contained scripts: screenshot + click, OCR, scheduler, remote desktop, agent loop, observability, recording, variables, hotkeys, triggers, reports, MCP, REST, secrets, plugins, computer use, Wayland, cross-host DAGs, chat-ops, pytest/BDD, anchor locators, action journals. | +| [`examples/`](examples/) | 30 self-contained scripts: screenshot + click, OCR, scheduler, remote desktop, agent loop, observability, recording, variables, hotkeys, triggers, reports, MCP, REST, secrets, plugins, computer use, Wayland, cross-host DAGs, chat-ops, pytest/BDD, anchor locators, action journals. | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | Full API reference, English and 中文. | | [architecture_explore.md](architecture_explore.md) | Every module's responsibility, layer by layer. | | [docs/CAPABILITY_MATRIX.md](docs/CAPABILITY_MATRIX.md) | Capability × platform matrix. | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index ef5a1789d..bbc0bf71b 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -19,7 +19,7 @@ - **一套 API,七个平台。** `wrapper/platform_wrapper.py` 在导入时挑选后端;同一份脚本在 Windows、macOS、X11 与 Wayland 上都不需要改写。 -- **不写 Python 也能脚本化。** 781 个 `AC_*` 命令覆盖全部功能,因此一个 JSON 文件能做到库 +- **不写 Python 也能脚本化。** 782 个 `AC_*` 命令覆盖全部功能,因此一个 JSON 文件能做到库 能做的任何事——包含循环、分支、try/catch、宏与变量。 - **默认无头运行。** `import je_auto_control` 绝不会加载 Qt。GUI 是可选包,包在同一个无头内核之外。 - **四种定位方式。** 模板匹配、OCR、无障碍树、视觉语言模型——可通过锚点定位器与自愈回退串接组合。 @@ -221,7 +221,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 自然语言规划 | `plan_actions`、`run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`、`run_agent` | `AC_run_agent` | Computer Use | | 录制与回放 | `record`、`stop_record` | `AC_record`、`AC_stop_record` | Record | -| JSON 脚本 | `execute_action`、`execute_files` | 全部 781 个命令 | Script、Script Builder | +| JSON 脚本 | `execute_action`、`execute_files` | 全部 782 个命令 | Script、Script Builder | | 变量与流程控制 | `execute_action_with_vars` | `AC_set_var`、`AC_loop`、`AC_for_each`、`AC_try`、`AC_retry` | Variables | | 数据驱动执行 | — | `AC_for_each_row`(CSV/JSON/SQLite/Excel) | Data Sources | | 断言 | `assert_text`、`assert_image` | `AC_assert_text` 等 21 个 | Assertions | @@ -235,7 +235,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | USB 枚举与直通 | `list_usb_devices`、`enable_usb_passthrough` | `AC_usb_*`(16 个命令) | USB Devices、USB Share | | 密钥保险库 | `default_secret_manager` | `AC_secret_set` + `${secrets.NAME}` | Secrets | | 报表(HTML/JSON/XML) | `generate_html_report` | `AC_generate_html_report` | Report | -| 运行历史 | `HistoryStore` | `AC_history_list` | Run History | +| 运行历史 | `HistoryStore`、`manual_history_scope` | `AC_history_list`、`AC_history_capture` | Run History、Script Executor、Script Builder | | 动作日志 | `execute_journal_run`、`inspect_journal` | `AC_journal_capture`、`AC_journal_read` | Run History、Script Builder | | 指标与追踪 | `default_metric_registry`、`render_metrics_text` | — | — | | 系统诊断 | `run_diagnostics` | `AC_diagnose` | Diagnostics | @@ -246,7 +246,9 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。`AC_journal_capture` 接受命名参数,在拥有者 executor 上执行嵌套 `body`,保留原变量作用域;`AC_journal_read` 与 CLI、公开的 `inspect_journal` 共用遮罩后的有界查询。两个命令都可在 Script Builder 编辑。捕获会在 history 链接、创建文件及执行动作前拒绝含有当次执行已知密钥值的标识符或目标路径。MCP 的 `ac_journal_capture` 与 `ac_journal_read` 提供结构化结果、进度与协作式取消。捕获的日志根目录限制与 observer 会保留到嵌套捕获及本机 workers;查询可在逐条验证时取消。Python 可通过 `JournalRunOptions` 与 `execute_journal_run` 使用相同控制。手动执行的 history 控制仍待接入。 +实验性的结构化日志已从 `je_auto_control` 导出(也可通过 `utils.action_journal` 使用):`journal_scope` 记录现有 executor 调用,`execute_action_with_journal` 隔离一次执行。本机 loop/parallel/DAG 步骤保留 parent ID,并区分失败、取消与 dry-run。现有 history 拥有者会自动链接显式捕获,包括本机 parallel/DAG worker 开启的 journal;`HistoryStore.journals_for_run` 可查询非拥有式链接,清理 history 不会删除日志文件。CLI 支持 `run script.json --journal run.jsonl [--journal-run-id ID]` 与 `journal run.jsonl [--run-id ID] [--limit 100]`。查询会按追加顺序显示遮罩后的生命周期快照,默认最多 100 条,上限 10,000 条。`AC_journal_capture` 接受命名参数,在拥有者 executor 上执行嵌套 `body`,保留原变量作用域;`AC_journal_read` 与 CLI、公开的 `inspect_journal` 共用遮罩后的有界查询。两个命令都可在 Script Builder 编辑。捕获会在 history 链接、创建文件及执行动作前拒绝含有当次执行已知密钥值的标识符或目标路径。MCP 的 `ac_journal_capture` 与 `ac_journal_read` 提供结构化结果、进度与协作式取消。捕获的日志根目录限制与 observer 会保留到嵌套捕获及本机 workers;查询可在逐条验证时取消。Python 可通过 `JournalRunOptions` 与 `execute_journal_run` 使用相同控制。 + +手动执行可通过 `manual_history_scope`、`AC_history_capture`、CLI 的 `run --history [--history-db PATH]`,或 Script Executor/Script Builder 的 **记录到运行历史** 选择记录。GUI 的可选日志路径也能独立使用;GUI/CLI 保留原有结果 JSON 与变量作用域,命令则返回 `history_id`、`record`、`failed_actions`。已有与嵌套捕获都会关联到新的手动历史记录。CLI 预览使用 `cli.preview` 标签,普通执行使用 `cli.run`。 Run History 提供只读日志查看。使用 **操作 → 使用所选执行的日志** 选择来源关联,或用 **选择日志** 指定外部文件;设置执行 ID 与快照上限后,使用 **读取日志**。开始/完成快照保持分离,选中后会显示完整的遮罩数据。**取消读取日志** 与关闭视图会在逐条边界请求取消;文件解析在后台 worker 执行,仅选择关联不会打开文件。 @@ -416,7 +418,7 @@ Windows、macOS(pyobjc)与 X11(含 XWayland);纯 Wayland 会话的协 | 资源 | 内容 | |---|---| -| [`examples/`](../examples/) | 29 个自包含脚本:截图点击、OCR、调度器、远程桌面、agent loop、可观测性、录制、变量、热键、触发器、报表、MCP、REST、密钥、插件、computer use、Wayland、跨主机 DAG、chat-ops、pytest/BDD、锚点定位、动作日志。 | +| [`examples/`](../examples/) | 30 个自包含脚本:截图点击、OCR、调度器、远程桌面、agent loop、可观测性、录制、变量、热键、触发器、报表、MCP、REST、密钥、插件、computer use、Wayland、跨主机 DAG、chat-ops、pytest/BDD、锚点定位、动作日志。 | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | 完整 API 参考,含英文与中文。 | | [architecture_explore.md](../architecture_explore.md) | 逐层记录每个模块的职责。 | | [docs/CAPABILITY_MATRIX.md](../docs/CAPABILITY_MATRIX.md) | 能力 × 平台对照矩阵。 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 2ae465860..d1d418185 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -18,7 +18,7 @@ - **一套 API,七個平台。** `wrapper/platform_wrapper.py` 在匯入時挑選後端;同一份腳本在 Windows、macOS、X11 與 Wayland 上都不需要改寫。 -- **不寫 Python 也能腳本化。** 781 個 `AC_*` 指令涵蓋全部功能,因此一個 JSON 檔能做到函式庫 +- **不寫 Python 也能腳本化。** 782 個 `AC_*` 指令涵蓋全部功能,因此一個 JSON 檔能做到函式庫 能做的任何事——包含迴圈、分支、try/catch、巨集與變數。 - **預設無頭執行。** `import je_auto_control` 絕不會載入 Qt。GUI 是選用套件,包在同一個無頭核心之外。 - **四種定位方式。** 樣板比對、OCR、無障礙樹、視覺語言模型——可透過錨點定位器與自癒後備串接組合。 @@ -220,7 +220,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 自然語言規劃 | `plan_actions`、`run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`、`run_agent` | `AC_run_agent` | Computer Use | | 錄製與重播 | `record`、`stop_record` | `AC_record`、`AC_stop_record` | Record | -| JSON 腳本 | `execute_action`、`execute_files` | 全部 781 個指令 | Script、Script Builder | +| JSON 腳本 | `execute_action`、`execute_files` | 全部 782 個指令 | Script、Script Builder | | 變數與流程控制 | `execute_action_with_vars` | `AC_set_var`、`AC_loop`、`AC_for_each`、`AC_try`、`AC_retry` | Variables | | 資料驅動執行 | — | `AC_for_each_row`(CSV/JSON/SQLite/Excel) | Data Sources | | 斷言 | `assert_text`、`assert_image` | `AC_assert_text` 等 21 個 | Assertions | @@ -234,7 +234,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | USB 列舉與直通 | `list_usb_devices`、`enable_usb_passthrough` | `AC_usb_*`(16 個指令) | USB Devices、USB Share | | 機密保險庫 | `default_secret_manager` | `AC_secret_set` + `${secrets.NAME}` | Secrets | | 報表(HTML/JSON/XML) | `generate_html_report` | `AC_generate_html_report` | Report | -| 執行歷史 | `HistoryStore` | `AC_history_list` | Run History | +| 執行歷史 | `HistoryStore`、`manual_history_scope` | `AC_history_list`、`AC_history_capture` | Run History、Script Executor、Script Builder | | 動作日誌 | `execute_journal_run`、`inspect_journal` | `AC_journal_capture`、`AC_journal_read` | Run History、Script Builder | | 指標與追蹤 | `default_metric_registry`、`render_metrics_text` | — | — | | 系統診斷 | `run_diagnostics` | `AC_diagnose` | Diagnostics | @@ -245,7 +245,9 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" --- -實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。`AC_journal_capture` 接受具名參數,在擁有者 executor 上執行巢狀 `body`,保留原變數範圍;`AC_journal_read` 與 CLI、公開的 `inspect_journal` 共用遮罩後的有界查閱。兩個指令皆可在 Script Builder 編輯。擷取會在 history 連結、開檔及動作執行前拒絕含有當次執行已知秘密的識別碼或目的地。MCP 的 `ac_journal_capture` 與 `ac_journal_read` 提供結構化結果、進度與合作式取消。擷取的日誌根目錄限制與 observer 會保留至巢狀擷取及本機 workers;查閱可在逐筆驗證時取消。Python 可透過 `JournalRunOptions` 與 `execute_journal_run` 使用相同控制。手動執行的 history 控制仍待接入。 +實驗性的結構化日誌已從 `je_auto_control` 匯出(也可透過 `utils.action_journal` 使用):`journal_scope` 記錄現有 executor 呼叫,`execute_action_with_journal` 隔離一次執行。本機 loop/parallel/DAG 步驟保留 parent ID,並區分失敗、取消與 dry-run。既有 history 擁有者會自動連結明確擷取,包含本機 parallel/DAG worker 開啟的 journal;`HistoryStore.journals_for_run` 可查詢非擁有式連結,清理 history 不會刪除日誌檔。CLI 支援 `run script.json --journal run.jsonl [--journal-run-id ID]` 與 `journal run.jsonl [--run-id ID] [--limit 100]`。查閱會按附加順序顯示遮罩後的生命週期快照,預設最多 100 筆,上限 10,000 筆。`AC_journal_capture` 接受具名參數,在擁有者 executor 上執行巢狀 `body`,保留原變數範圍;`AC_journal_read` 與 CLI、公開的 `inspect_journal` 共用遮罩後的有界查閱。兩個指令皆可在 Script Builder 編輯。擷取會在 history 連結、開檔及動作執行前拒絕含有當次執行已知秘密的識別碼或目的地。MCP 的 `ac_journal_capture` 與 `ac_journal_read` 提供結構化結果、進度與合作式取消。擷取的日誌根目錄限制與 observer 會保留至巢狀擷取及本機 workers;查閱可在逐筆驗證時取消。Python 可透過 `JournalRunOptions` 與 `execute_journal_run` 使用相同控制。 + +手動執行可透過 `manual_history_scope`、`AC_history_capture`、CLI 的 `run --history [--history-db PATH]`,或 Script Executor/Script Builder 的 **記錄至執行歷史** 選用紀錄。GUI 的選填日誌路徑也能獨立使用;GUI/CLI 保留原本的結果 JSON 與變數範圍,指令則回傳 `history_id`、`record`、`failed_actions`。既有與巢狀擷取都會連結到新的手動歷史列。CLI 預覽以 `cli.preview` 標示,一般執行使用 `cli.run`。 Run History 提供唯讀日誌檢視。使用 **操作 → 使用所選執行的日誌** 選擇來源連結,或用 **選擇日誌** 指定外部檔案;設定執行 ID 與快照上限後,使用 **讀取日誌**。起始/完成快照保持分離,選取後會顯示完整的遮罩資料。**取消讀取日誌** 與關閉檢視會在逐筆邊界提出取消要求;檔案解析在背景 worker 執行,單純選取連結不會開檔。 @@ -416,7 +418,7 @@ Windows、macOS(pyobjc)與 X11(含 XWayland);純 Wayland session 的 | 資源 | 內容 | |---|---| -| [`examples/`](../examples/) | 29 個自足腳本:截圖點擊、OCR、排程器、遠端桌面、agent loop、可觀測性、錄製、變數、熱鍵、觸發器、報表、MCP、REST、機密、外掛、computer use、Wayland、跨主機 DAG、chat-ops、pytest/BDD、錨點定位、動作日誌。 | +| [`examples/`](../examples/) | 30 個自足腳本:截圖點擊、OCR、排程器、遠端桌面、agent loop、可觀測性、錄製、變數、熱鍵、觸發器、報表、MCP、REST、機密、外掛、computer use、Wayland、跨主機 DAG、chat-ops、pytest/BDD、錨點定位、動作日誌。 | | [Read the Docs](https://autocontrol.readthedocs.io/en/latest/) | 完整 API 參考,含英文與中文。 | | [architecture_explore.md](../architecture_explore.md) | 逐層記錄每個模組的職責。 | | [docs/CAPABILITY_MATRIX.md](../docs/CAPABILITY_MATRIX.md) | 能力 × 平台對照矩陣。 | diff --git a/architecture.md b/architecture.md index 1260864cc..33f844464 100644 --- a/architecture.md +++ b/architecture.md @@ -160,6 +160,14 @@ request generations reject old results after a target change. Closing the view or destroying its owner requests cooperative cancellation. The existing daemon worker relay drops callbacks after owner destruction. Selecting history links loads metadata only; neither inspection nor link selection creates a history row. +`run_history/manual.py` creates explicitly requested manual owners around an +existing execution. It binds both an ambient journal and captures opened later, +retains caller variables/results, counts only the current counter delta and +keeps normal loop signals distinct from failures. Original signals survive a +failed finish; labels are scrubbed before insertion. The named-body command +adapter stays on the owning executor. CLI opt-in and the shared GUI +`ManualRunControls` wrap existing run paths; only a CLI-owned custom store is +closed by the CLI. Default and journal-only paths open no history database. `utils/run_history/context.py` binds an existing history row to execution. Scheduler/trigger/hotkey/webhook/email owners bind their own row; local parallel and DAG workers carry it explicitly. `journal_scope` attaches an idempotent diff --git a/architecture_explore.md b/architecture_explore.md index bda8b8c9b..4cbb6c8cf 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,15 +19,15 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,093 | -| 程式碼總行數 | 163,915 | +| Python 模組總數(含周邊子專案) | 1,096 | +| 程式碼總行數 | 164,138 | | `je_auto_control/utils/` 子套件數 | 311 | -| `AC_*` 動作指令數(`known_commands()` 實測) | 781 | -| 套件門面 `__all__` 公開名稱數 | 1,277 | +| `AC_*` 動作指令數(`known_commands()` 實測) | 782 | +| 套件門面 `__all__` 公開名稱數 | 1,279 | | GUI 分頁數(`main_widget` 註冊) | 48 | | MCP 工具數(`build_default_tool_registry()` 實測) | 682 | -| `test_*.py` 測試檔/測試函式 | 767 / 8,083 | -| 範例腳本 | 29 | +| `test_*.py` 測試檔/測試函式 | 771 / 8,127 | +| 範例腳本 | 30 | **技術基線**:Python ≥ 3.10、MIT 授權、必要相依只有 `je_open_cv`/`opencv-python`/`pillow`/`mss`/ `defusedxml`/`cryptography`(加上各平台專屬的 pyobjc、python-Xlib);其餘全部是選用 extras @@ -92,7 +92,7 @@ USB/IP 協定、Prometheus 指標),以維持這條輕相依基線。 | 模式 | 落點 | 說明 | | --- | --- | --- | | **Strategy** | `wrapper/platform_wrapper.py` | 依 `sys.platform` 只匯入當前 OS 的 `keyboard`/`mouse`/`screen`/`recorder` 實作;Linux 再細分 Wayland/X11,Wayland 後端不可用時自動退回 XWayland 並記警告。新增平台不需要改 wrapper。 | -| **Facade** | `je_auto_control/__init__.py` | 把 1,277 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | +| **Facade** | `je_auto_control/__init__.py` | 把 1,279 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | | **Command** | `utils/executor/action_executor.py` | `Executor.event_dict` 是字串 → callable 的分派表;JSON 動作檔即指令序列,因此可錄製、序列化、重播、簽章。 | | **Observer** | `utils/callback/`、`utils/observer/`、`utils/triggers/` | 動作完成後觸發回呼;畫面出現/消失/變化與外部事件(webhook/IMAP/檔案)驅動腳本。 | | **Template Method** | `utils/generate_report/` | HTML/JSON/XML 三個產生器共用「收集紀錄 → 格式化 → 寫檔」骨架,各自實作渲染。 | @@ -154,9 +154,9 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `je_auto_control/__init__.py` | 2,001 | **套件門面**。集中匯入並再匯出 1,277 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | +| `je_auto_control/__init__.py` | 2,003 | **套件門面**。集中匯入並再匯出 1,279 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | | `je_auto_control/__main__.py` | 106 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | -| `je_auto_control/cli.py` | 416 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`/`--journal`)、`journal`(依 run 篩選與有界遮罩快照)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | +| `je_auto_control/cli.py` | 437 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`/`--journal`)、`journal`(依 run 篩選與有界遮罩快照)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | | `je_auto_control/api/core.py` | 19 | **穩定無頭 API 門面**:只暴露 `execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、`create_failure_bundle`、`failure_bundle_on_error`、`FailureBundleOptions`。mypy 型別契約以此為起點,現已擴到整包(見「設定基線」)。 | | `je_auto_control/utils/cli_output.py` | 44 | 本套件命令列工具與 stdio 伺服器的標準串流:不論碼頁一律 UTF-8。 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 16,082 行。 +> 25 個套件、約 16,085 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -284,7 +284,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | | `utils/action_journal/` | 780 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;門面、AC/MCP、Script Builder 與 CLI 擷取/有界查閱;observer/路徑政策隨巢狀根與 workers 保留,專用 GUI/手動 history 待補。 | -| `utils/executor/` | 9,704 | **核心**。`Executor` 指令分派表(781 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/executor/` | 9,707 | **核心**。`Executor` 指令分派表(781 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -559,7 +559,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.12 報表、可觀測性與測試治理 -> 34 個套件、約 7,870 行。 +> 34 個套件、約 7,978 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -584,7 +584,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/profiler/` | 451 | 逐動作效能剖析器 + 資源剖析器 | | `utils/quarantine/` | 204 | 易碎測試隔離區,讓套件執行器跳過已知不穩定案例 | | `utils/run_diff/` | 143 | 兩次執行軌跡的差異(LCS 對齊:新增/移除/狀態翻轉/退化) | -| `utils/run_history/` | 603 | 執行歷史、產出物與非擁有式 journal 連結;既有 run 的 context 傳入本機 workers,清理 history 不刪日誌檔 | +| `utils/run_history/` | 711 | 執行歷史、產出物與非擁有式 journal 連結;明確手動 scope 與既有 owner 的 context 傳入本機 workers,清理 history 不刪日誌檔 | | `utils/sarif/` | 187 | 以 SARIF 2.1.0 匯出發現項,供 GitHub/Azure code scanning | | `utils/slo/` | 115 | SLO 評估:SLI、錯誤預算與多視窗燃燒率告警 | | `utils/smoothing/` | 67 | 數列移動平均平滑 | @@ -631,7 +631,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.14 安全、機密與合規 -> 13 個套件、約 3,527 行。 +> 13 個套件、約 3,528 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -640,7 +640,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/governance/` | 242 | 治理:maker-checker 核准閘門與即時憑證租約 | | `utils/license_policy/` | 240 | 以 SBOM 元件評估 SPDX 授權允許/拒絕政策 | | `utils/provenance/` | 126 | SLSA 建置來源證明(in-toto v1) | -| `utils/rbac/` | 794 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | +| `utils/rbac/` | 795 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | | `utils/redaction/` | 508 | 截圖遮蔽層:規則偵測 + 政策 + 協調器(上傳 VLM 前先遮) | | `utils/sbom/` | 170 | SBOM(CycloneDX)產生 | | `utils/secret_ref/` | 238 | URI scheme 形式的值參照解析 | @@ -697,14 +697,14 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,704 行)— 執行核心 +#### `utils/executor/`(9,707 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,450 | `Executor` 類別與 `event_dict` 分派表(781 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `action_executor.py` | 8,452 | `Executor` 類別與 `event_dict` 分派表(781 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | | `flow_control.py` | 678 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | -| `action_schema.py` | 160 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | +| `action_schema.py` | 161 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | | `action_redaction.py` | 86 | 記錄與紀錄鍵用的遮蔽:`AC_secret_*` 的參數(金庫通行碼、機密值)在寫進 log、當成結果紀錄的鍵之前換成 `***`,巢狀在區塊指令裡的也一樣。 | | `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 | | `flags.py` | 19 | 旗標讀取 `as_bool`:依拼法(`"true"`/`"yes"`/`"on"`/`"1"`)而非 Python 真值判斷,所以 JSON、CLI、MCP 傳來的 `"false"` 是關;所有 adapter 與流程指令共用。 | @@ -857,7 +857,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `action_lint/` | `linter.py`、`schema.py`、`__main__.py`(CI 使用) | | `time_travel/` | `controller.py`、`player.py` | | `dag/` | `graph.py`、`runner.py` | -| `run_history/` | `history_store.py`、`context.py`(非擁有式 journal 連結與 worker 擁有者)、`artifact_manager.py`、`run_outcome.py`(一般失敗計數、中止分類與保留硬中止的歷史結算) | +| `run_history/` | `history_store.py`、`context.py`(非擁有式 journal 連結與 worker 擁有者)、`manual.py`/`commands.py`(明確手動 run、正常 loop 訊號與原始結果保留)、`artifact_manager.py`、`run_outcome.py`(一般失敗計數、中止分類與保留硬中止的歷史結算) | | `self_healing/` | `locator.py`、`heal_log.py` | | `ab_locator/` | `runner.py`、`store.py` | | `cost_telemetry/` | `pricing.py`、`store.py` | @@ -884,7 +884,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | --- | ---: | --- | | `gui/__init__.py` | 25 | `start_autocontrol_gui()`:**唯一**會延遲匯入 PySide6 的地方,維持頂層套件 Qt-free。 | | `main_window.py` | 407 | `QMainWindow`:選單列(File/Actions/View/…)、左側導覽面板 dock(`Ctrl+K` 搜尋、`Ctrl+B` 收合)、即時語言切換、字級預設、深色/淺色主題(`theme.py` 的 token,不再用 qt-material)。分頁分為 core/editing/detection/automation/system 五類。 | -| `main_widget.py` | 390 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/close/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | +| `main_widget.py` | 392 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/close/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | | `tab_registry.py` | 183 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。`TabRegistry.open/close` 快取或釋放 factory 面板,保留 metadata 與既有表單;不匯入 Qt。 | | `navigation.py` | 200 | `NavigationPanel`:搜尋框 + 依分類的功能樹,列出每個已註冊分頁(開啟中的以粗體標示),只回報被選的鍵,開啟分頁仍由視窗負責。 | | `theme.py` | 178 | 設計 token(`ThemeTokens`:顏色、圓角、間距、字族)、深色與淺色兩組值、由 token 產生的樣式表與對應的 `QPalette`;不載入圖檔或字型檔。 | @@ -893,7 +893,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `_auto_click_tab.py` | 291 | 自動點擊分頁的 mixin 建構器。 | | `_screenshot_tab.py` | 137 | 截圖/取像素分頁 mixin。 | | `_image_detect_tab.py` | 115 | 影像偵測分頁 mixin。 | -| `_script_tab.py` | 115 | 腳本執行分頁 mixin。 | +| `_script_tab.py` | 124 | 腳本執行分頁 mixin。 | | `_record_tab.py` | 114 | 錄製/回放分頁 mixin。 | | `_report_tab.py` | 88 | 報表分頁 mixin。 | | `_i18n_helpers.py` | 66 | 需要即時語言切換的分頁共用的翻譯註冊 mixin。 | @@ -903,65 +903,66 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `_worker_thread.py` | 216 | `start_worker()`:在 daemon `threading.Thread` 上執行 `QObject` worker 的 `run()`(沒有 `QThread` 可被銷毀),並經由分頁擁有的中繼物件回報結果(回呼一律在 GUI 執行緒;worker 沒處理的例外也送到 `on_fail`);worker 留在模組登錄表直到 GUI 執行緒看到它結束,回傳 `WorkerHandle`(`isRunning()`);程式結束時先呼叫 worker 的 `request_stop()`,最多等 10 秒,仍在跑的隨行程結束。 | | `journal_browser.py` | 221 | Run History 內的唯讀日誌檢視:來源連結/路徑/run 篩選、有界快照表與明細,舊結果以 request ID 隔離。 | | `journal_browser_worker.py` | 84 | 背景查閱與逐筆取消;帶入 context 並在開檔前重查 deferred 擁有者,以 queued counts 回報進度。 | -| `language_wrapper/` | 5,175 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | +| `manual_run_controls.py` | 53 | 手動執行的 history 勾選與獨立 journal 路徑;以共用無頭 scope 包住既有 GUI 執行,不改結果與變數。 | +| `language_wrapper/` | 5,183 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | | `selector/` | 216 | 拖曳選取螢幕區域的半透明全螢幕覆蓋層與樣板裁切工具(互動式,但都有對應的程式化 API)。 | > **分頁指令一律走 Actions 選單**:分頁本身只放輸入、表格與結果檢視,指令由視窗層選單暴露。 -> 核心分頁在 `main_widget.py` 的 `_own_tab_builders` 宣告動作;功能分頁實作 `menu_actions()`(目前 40 個檔案有此 hook)。 +> 核心分頁在 `main_widget.py` 的 `_own_tab_builders` 宣告動作;功能分頁實作 `menu_actions()`(目前 41 個檔案有此 hook)。 > `test/unit_test/headless/test_actions_menu_gui.py` 會守住這個契約——沒有動作宣告的新分頁會讓 CI 失敗。 #### 48 個分頁 | 分頁 | 模組 | 行數 | 職責 | | --- | --- | ---: | --- | -| auto_click | `_auto_click_tab.py` | 270 | 自動點擊:座標、間隔、熱鍵、`write`、捲動。 | -| screenshot | `_screenshot_tab.py` | 127 | 截圖、選區、螢幕尺寸、取像素色。 | -| image_detect | `_image_detect_tab.py` | 106 | 樣板裁切、定位、定位全部、定位並點擊。 | -| record | `_record_tab.py` | 101 | 錄製/停止/回放/存檔/載入。 | -| script_builder | `script_builder/` | 5,708 | **視覺化腳本編輯器**:`command_schema.py`(4,924 行 `AC_*` 參數綱要)、`step_model.py`(步驟模型與 AC JSON 序列化)、`step_list_view.py`(含巢狀 body 的樹狀檢視)、`step_form_view.py`(綱要驅動表單)、`builder_tab.py`。 | +| auto_click | `_auto_click_tab.py` | 291 | 自動點擊:座標、間隔、熱鍵、`write`、捲動。 | +| screenshot | `_screenshot_tab.py` | 137 | 截圖、選區、螢幕尺寸、取像素色。 | +| image_detect | `_image_detect_tab.py` | 115 | 樣板裁切、定位、定位全部、定位並點擊。 | +| record | `_record_tab.py` | 114 | 錄製/停止/回放/存檔/載入。 | +| script_builder | `script_builder/` | 6,020 | **視覺化腳本編輯器**:`command_schema.py`(5,170 行 `AC_*` 參數綱要)、`step_model.py`(步驟模型與 AC JSON 序列化)、`step_list_view.py`(含巢狀 body 的樹狀檢視)、`step_form_view.py`(綱要驅動表單)、`builder_tab.py`。 | | flow_editor | `flow_editor/` | 490 | 節點式流程圖檢視:`layout.py`(純 Python 佈局演算法,可單測)、`scene.py`(Qt 場景繪製)、`tab.py`。 | -| script | `_script_tab.py` | 105 | 載入/執行單檔或整個目錄、內建編輯器執行。 | +| script | `_script_tab.py` | 124 | 載入/執行單檔或整個目錄、內建編輯器執行。 | | recording_editor | `recording_editor_tab.py` | 244 | 裁切、過濾、重新縮放錄製內容。 | -| variables | `variables_tab.py` | 166 | 檢視、灌入、清除 executor 執行期作用域。 | -| secrets | `secrets_tab.py` | 188 | 解鎖保險庫並管理 `${secrets.NAME}`。 | -| vlm | `vlm_tab.py` | 112 | 用文字描述 UI 元素,交由模型定位。 | +| variables | `variables_tab.py` | 169 | 檢視、灌入、清除 executor 執行期作用域。 | +| secrets | `secrets_tab.py` | 187 | 解鎖保險庫並管理 `${secrets.NAME}`。 | +| vlm | `vlm_tab.py` | 113 | 用文字描述 UI 元素,交由模型定位。 | | self_healing | `self_healing_tab.py` | 195 | 自癒定位器管理與治癒記錄。 | -| ocr_reader | `ocr_tab.py` | 170 | 傾印區域文字或以 regex 搜尋。 | -| accessibility | `accessibility_tab.py` | 131 | 瀏覽 OS UI 樹並依 role/name 點擊。 | -| live_hud | `live_hud_tab.py` | 99 | 即時 HUD:滑鼠位置、游標下像素色、log tail。 | -| llm_planner | `llm_planner_tab.py` | 182 | 自然語言描述 → 預覽 → 執行。 | -| computer_use | `computer_use_tab.py` | 166 | 從 GUI 啟動 Anthropic 閉環 agent。 | -| scheduler | `scheduler_tab.py` | 138 | 註冊間隔式 action JSON 執行。 | -| hotkeys | `hotkeys_tab.py` | 130 | 將全域熱鍵綁到 action 檔。 | -| triggers | `triggers_tab.py` | 321 | 影像/視窗/像素/檔案事件監看。 | -| webhooks | `webhooks_tab.py` | 210 | 將 HTTP 請求綁到 action 腳本。 | -| email_triggers | `email_triggers_tab.py` | 224 | 將 IMAP 信箱綁到 action 腳本。 | -| test_suite | `test_suite_tab.py` | 163 | 執行 QA 套件規格並管理易碎隔離區。 | -| assertions | `assertions_tab.py` | 130 | 執行單一畫面狀態斷言並顯示通過/失敗。 | +| ocr_reader | `ocr_tab.py` | 169 | 傾印區域文字或以 regex 搜尋。 | +| accessibility | `accessibility_tab.py` | 153 | 瀏覽 OS UI 樹並依 role/name 點擊。 | +| live_hud | `live_hud_tab.py` | 128 | 即時 HUD:滑鼠位置、游標下像素色、log tail。 | +| llm_planner | `llm_planner_tab.py` | 169 | 自然語言描述 → 預覽 → 執行。 | +| computer_use | `computer_use_tab.py` | 170 | 從 GUI 啟動 Anthropic 閉環 agent。 | +| scheduler | `scheduler_tab.py` | 152 | 註冊間隔式 action JSON 執行。 | +| hotkeys | `hotkeys_tab.py` | 144 | 將全域熱鍵綁到 action 檔。 | +| triggers | `triggers_tab.py` | 354 | 影像/視窗/像素/檔案事件監看。 | +| webhooks | `webhooks_tab.py` | 209 | 將 HTTP 請求綁到 action 腳本。 | +| email_triggers | `email_triggers_tab.py` | 238 | 將 IMAP 信箱綁到 action 腳本。 | +| test_suite | `test_suite_tab.py` | 162 | 執行 QA 套件規格並管理易碎隔離區。 | +| assertions | `assertions_tab.py` | 138 | 執行單一畫面狀態斷言並顯示通過/失敗。 | | data_source | `data_source_tab.py` | 136 | 預覽無頭資料層載入的資料列。 | -| flakiness | `flakiness_tab.py` | 117 | 依執行歷史排序間歇失敗的腳本。 | -| a11y_audit | `a11y_audit_tab.py` | 114 | 從即時樹找出無障礙/i18n 缺陷。 | -| device_matrix | `device_matrix_tab.py` | 111 | 一份 action list 跨多裝置平行執行。 | +| flakiness | `flakiness_tab.py` | 116 | 依執行歷史排序間歇失敗的腳本。 | +| a11y_audit | `a11y_audit_tab.py` | 113 | 從即時樹找出無障礙/i18n 缺陷。 | +| device_matrix | `device_matrix_tab.py` | 110 | 一份 action list 跨多裝置平行執行。 | | media_checks | `media_checks_tab.py` | 116 | 音訊活動與影片動態斷言。 | -| run_history | `run_history_tab.py` + `run_history_timeline.py` | 462 | 瀏覽已記錄的執行、時間軸、截圖與非擁有式日誌連結;內嵌背景快照查閱。 | -| profiler | `profiler_tab.py` | 131 | 視覺化逐動作耗時熱點。 | -| window_manager | `window_tab.py` | 128 | 列出、聚焦、關閉視窗。 | -| plugins | `plugins_tab.py` | 83 | 從使用者目錄載入額外 `AC_` 指令。 | -| webrunner | `webrunner_tab.py` | 188 | 從 GUI 驅動 `je_web_runner`。 | -| dag_runner | `dag_tab.py` | 188 | 編輯、驗證、執行跨主機 DAG。 | -| chatops | `chatops_tab.py` | 108 | 在接上 Slack 前先本機測試 slash 指令。 | -| trace_replay | `trace_replay_tab.py` | 187 | 拖曳捲動時光回溯錄製內容。 | -| remote_desktop | `remote_desktop/`(17 檔) | 6,240 | 見下。 | -| presence | `presence_tab.py` | 152 | 多檢視者在場名單。 | -| rest_api | `rest_api_tab.py` | 198 | 啟停 HTTP 前端並顯示 URL、實際驗證模式與可用 token。 | -| admin_console | `admin_console_tab.py` | 313 | 管理多個遠端 AutoControl REST 端點。 | -| audit_log | `audit_log_tab.py` | 192 | 瀏覽並驗證防竄改雜湊鏈。 | -| inspector | `inspector_tab.py` | 121 | WebRTC 檢測器:即時摘要與近期統計取樣。 | -| usb_devices | `usb_devices_tab.py` | 121 | 唯讀列舉 + 熱插拔監看控制。 | -| usb_browser | `usb_browser_tab.py` | 310 | 檢視端 USB 裝置瀏覽器。 | -| usb_share | `usb_passthrough_panel.py` + `usb_passthrough_prompt.py` | 704 | AnyDesk 風格 USB 直通面板與主機端 ACL 授權對話框。 | -| diagnostics | `diagnostics_tab.py` | 91 | 執行子系統檢查並顯示結果。 | -| report | `_report_tab.py` | 81 | 產生 HTML/JSON/XML 報表。 | +| run_history | `run_history_tab.py` + `run_history_timeline.py` + `journal_browser.py` + `journal_browser_worker.py` | 792 | 瀏覽已記錄的執行、時間軸、截圖與非擁有式日誌連結;內嵌背景快照查閱。 | +| profiler | `profiler_tab.py` | 130 | 視覺化逐動作耗時熱點。 | +| window_manager | `window_tab.py` | 137 | 列出、聚焦、關閉視窗。 | +| plugins | `plugins_tab.py` | 82 | 從使用者目錄載入額外 `AC_` 指令。 | +| webrunner | `webrunner_tab.py` | 187 | 從 GUI 驅動 `je_web_runner`。 | +| dag_runner | `dag_tab.py` | 195 | 編輯、驗證、執行跨主機 DAG。 | +| chatops | `chatops_tab.py` | 107 | 在接上 Slack 前先本機測試 slash 指令。 | +| trace_replay | `trace_replay_tab.py` | 192 | 拖曳捲動時光回溯錄製內容。 | +| remote_desktop | `remote_desktop/`(17 檔) | 6,735 | 見下。 | +| presence | `presence_tab.py` | 155 | 多檢視者在場名單。 | +| rest_api | `rest_api_tab.py` | 199 | 啟停 HTTP 前端並顯示 URL、實際驗證模式與可用 token。 | +| admin_console | `admin_console_tab.py` | 310 | 管理多個遠端 AutoControl REST 端點。 | +| audit_log | `audit_log_tab.py` | 191 | 瀏覽並驗證防竄改雜湊鏈。 | +| inspector | `inspector_tab.py` | 120 | WebRTC 檢測器:即時摘要與近期統計取樣。 | +| usb_devices | `usb_devices_tab.py` | 160 | 唯讀列舉 + 熱插拔監看控制。 | +| usb_browser | `usb_browser_tab.py` | 280 | 檢視端 USB 裝置瀏覽器。 | +| usb_share | `usb_passthrough_panel.py` + `usb_passthrough_prompt.py` | 739 | AnyDesk 風格 USB 直通面板與主機端 ACL 授權對話框。 | +| diagnostics | `diagnostics_tab.py` | 90 | 執行子系統檢查並顯示結果。 | +| report | `_report_tab.py` | 88 | 產生 HTML/JSON/XML 報表。 | #### 遠端桌面 GUI(`gui/remote_desktop/`,19 檔/6,735 行) @@ -995,7 +996,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `AutoControl/executor/` | 3 檔 | 由 `create_project_dir` 產生的專案範本示範(單檔/整個目錄/錯誤檔案三種執行方式)。 | | `exe/start_autocontrol_gui.py` | 4 行 | 打包成執行檔用的 GUI 啟動器。 | | `benchmarks/core_latency.py` | 32 行 | 對穩定無頭進入點的可重複煙霧基準測試。 | -| `examples/` | 27 個腳本 | 從截圖點擊、OCR、排程、遠端桌面、agent loop、可觀測性,一路到 computer-use、Wayland、跨主機 DAG、chatops、pytest/BDD、anchor locator。 | +| `examples/` | 30 個腳本 | 從截圖點擊、OCR、排程、遠端桌面、agent loop、可觀測性,一路到 computer-use、Wayland、跨主機 DAG、chatops、pytest/BDD、anchor locator。 | | `browser-extension/` | manifest v3 擴充 | 瀏覽器端配合元件(background/content script/popup)。 | | `docker/` | Dockerfile ×8 + compose + 9 支驗證/伺服器腳本 | 無頭容器(`Dockerfile`)、帶 XFCE 桌面的容器(`Dockerfile.xfce`),以及四個**驗證用**映像:`Dockerfile.wayland`(sway headless,擷取路徑 + `libei_verify.py` 對真的 libei.so 解析符號)、`Dockerfile.eis`(`eis_server.py` 用 ctypes 綁 libeis 起一個真的 EIS server,`eis_verify.py` 把 libei sender 對著它跑完整握手與發送)、`Dockerfile.portal`(`portal_server.py` 自己佔住 `org.freedesktop.portal.Desktop`,真的 `dbus-daemon` + 真的 liboeffis 跑完 RemoteDesktop 交握)、`Dockerfile.ydotool`(真的 uinput 裝置,`ydotool_verify.py` 直接讀回 `/dev/input/eventN`)、`Dockerfile.seat`(`headless,libinput` + builtin seat,合成器真的吃下 ydotool 裝置,`seat_verify.py` 從 `grim -c` 的像素讀回游標落點)、`Dockerfile.x11`(真的 Xvfb + openbox,`x11_verify.py` 用 `xev` 把注入的事件從真的客戶端讀回(含 `synthetic NO`,這是 XTest 跟 `XSendEvent` 的差別),另用 ImageMagick `import` 做獨立擷取對照,跑兩種螢幕版面)。全部接在 `.github/workflows/docker.yml`。 | | `k8s/helm/` | Helm chart | Kubernetes 部署。 | @@ -1003,7 +1004,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `scripts/dev_release.py` | 只用標準函式庫的發佈輔助腳本 | dev 通道(PyPI 的 `je_auto_control_dev`)的版本與比對:`prepare` 把 `dev.toml` 寫成 `pyproject.toml`,版本取 PyPI 最新一版加一個 patch(`dev.toml` 的版本只是下限,不回寫 repo);`changed dist` 把建好的 wheel 跟 PyPI 最新一版逐檔比對,內容沒變就不上傳。只給 `dev.yml` 的 `publish-dev` job 與拋棄式的 checkout 用,因為 `prepare` 會蓋掉 `pyproject.toml`。 | | `docs/` | Sphinx(`API`/`Eng`/`Zh`/`getting_started`) | Read the Docs 文件。 | | `architecture_diagram/` | drawio + png | 既有的架構圖原始檔。 | -| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 767 個 `test_*.py`/8,083 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | +| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 771 個 `test_*.py`/8,127 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | --- @@ -1085,12 +1086,12 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 102 | 29,239 | +| `gui/` | 103 | 29,327 | | `utils/mcp_server/` | 38 | 19,323 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,704 | +| `utils/executor/` | 8 | 9,707 | | `utils/usb/` | 17 | 4,816 | -| `je_auto_control/`(頂層 3 檔) | 3 | 2,523 | +| `je_auto_control/`(頂層 3 檔) | 3 | 2,546 | | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | @@ -1105,6 +1106,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 700 | 59,877 | -| **總計** | **1,087** | **163,850** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 702 | 59,986 | +| **總計** | **1,090** | **164,073** | diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index ec282582a..7196f346c 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -8,7 +8,7 @@ project's facade rule. Membership in its `__all__` does not promote a feature into the versioned stable API. Structured action-journal storage, capture/read helpers and history provenance -links are experimental while B1 interface integration remains in progress. +links remain experimental, including their explicit manual-run integration. The documented names are available through `je_auto_control` and `je_auto_control.utils.action_journal`; `JournalLink` is also exported from `je_auto_control.utils.run_history`. CLI journal capture/inspection uses the same @@ -26,7 +26,10 @@ inherit observers and intersect policies; callbacks may run on joined worker threads. `MCPToolResult` is an experimental JSON-compatible object result with separate controlled-failure metadata. The MCP journal tools remain experimental alongside the other B1 journal APIs. Run History inspection uses these same -experimental snapshots; manual-run history ownership remains pending. +experimental snapshots. `manual_history_scope` and its `HistoryBinding` value, +`AC_history_capture`, CLI history flags and manual GUI controls are also +experimental. The scope records an explicit owner without replacing variables +or result values; the command returns a separate history/result summary. - Stable API removal requires a deprecation warning and two minor releases. - Beta API removal requires one release note and one minor release. diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index 2bdde2ce1..a2d66c4a0 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -27,7 +27,7 @@ Hardware-backed results and known limitations should be attached to releases. Journal storage, executor capture, provenance links, facade/CLI entry points and Script Builder commands, observed runs and MCP journal tools have controlled tests. The journal work does not claim native device acceptance. Run History journal browsing also has controlled offscreen tests. -Manual history ownership remains pending B1 integration. +Explicit manual history ownership has controlled API, command, CLI, GUI and MCP execution tests. Linux Wayland is split: **capture is exercised by CI against a real compositor; input is exercised by CI against a real EI peer and a real portal.** diff --git a/docs/source/Eng/doc/cli/cli_doc.rst b/docs/source/Eng/doc/cli/cli_doc.rst index 191e19595..92ee93cba 100644 --- a/docs/source/Eng/doc/cli/cli_doc.rst +++ b/docs/source/Eng/doc/cli/cli_doc.rst @@ -25,7 +25,7 @@ Run a script ``--var name=value`` is parsed as JSON when the value parses, otherwise it is treated as a plain string. ``--dry-run`` records every action -through the executor without invoking any side effects. Each invocation uses +through the executor without invoking action effects. Each invocation uses an independent variable scope, with or without ``--var``; seeds and action-written variables are discarded on success or error, including when called repeatedly from the same Python process. @@ -61,6 +61,24 @@ Reading applies the journal's masking policy again and never changes the file. Both journal paths use the existing allowed-root guard; configure ``AUTOCONTROL_ALLOWED_PATH_ROOTS`` for destinations outside the default roots. +Record explicit manual history +------------------------------- + +.. code-block:: bash + + python -m je_auto_control.cli run script.json --history + python -m je_auto_control.cli run script.json --history --history-db history.sqlite --journal run.jsonl + python -m je_auto_control.cli run script.json --history --dry-run + +``--history`` creates a manual owner in the default Run History store. +``--history-db PATH`` requires that flag, uses the allowed-root guard and closes +the custom store on exit; it does not switch the GUI's default store. +Existing/nested journal captures link to the owner. Normal stdout records, +variable scopes and failure exit codes are preserved. Executor-counted failures +and propagated errors/cancellation settle as ``error``. Previews use source +label ``cli.preview``; real runs use ``cli.run``. Without history opt-in, +including journal-only runs, no history database is opened. + List scheduler jobs ------------------- diff --git a/docs/source/Eng/doc/new_features/new_features_doc.rst b/docs/source/Eng/doc/new_features/new_features_doc.rst index bec31aac8..af7b993d6 100644 --- a/docs/source/Eng/doc/new_features/new_features_doc.rst +++ b/docs/source/Eng/doc/new_features/new_features_doc.rst @@ -1474,14 +1474,14 @@ with ``${secrets.NAME}`` so passwords never appear in the JSON. GUI: **Email Triggers** tab — register IMAP triggers, start/stop the watcher, run a manual poll, inspect last error and fire counter. -Structured action journals (partial integration) +Structured action journals (experimental) ================================================ B1 exports storage and explicit capture/read APIs from ``je_auto_control`` and ``je_auto_control.utils.action_journal``. CLI capture and bounded snapshot reads are available, along with action commands, Script Builder forms and dedicated -MCP capture/read tools and read-only inspection in Run History. Manual-run -history controls remain pending integration. The journal/provenance APIs remain experimental during B1. +MCP capture/read tools, read-only inspection in Run History and explicit +manual-run history controls. The journal/provenance APIs remain experimental during B1. Applications can explicitly write schema-v1 snapshots:: import time @@ -1669,7 +1669,66 @@ so removal or demotion is honored. Workers retain the submitted context's journal path restrictions as well as the default root guard. A rejected or corrupt source is shown as an error, never as an empty successful read. The controls, headers and state labels support all four GUI languages. -Manual execution's history ownership remains tracked in ``Progress.md``. + + +Explicit manual history +------------------------- + +``manual_history_scope(store=None, source_id="manual", script_path="")`` +creates an explicitly requested manual row and yields a frozen ``HistoryBinding`` +with ``store`` and ``run_id``. Both names are exported from ``je_auto_control``. +``source_id`` and ``script_path`` are non-empty metadata labels; they do not +open or execute a script. Known resolved secrets are masked before insertion. +The caller's executor, variables and result values are preserved. For example:: + + from je_auto_control import HistoryStore, manual_history_scope, execute_journal_run + + store = HistoryStore("history.sqlite") + try: + with manual_history_scope(store=store, source_id="manual-example") as owner: + result = execute_journal_run( + [["AC_set_var", {"name": "message", "value": "hello"}]], + "events.jsonl", + ) + links = store.journals_for_run(owner.run_id) + finally: + store.close() + +An ambient journal is linked when the manual scope starts. Captures opened +later, including local parallel/DAG captures, retain that owner. Nested manual +owners create separate rows and restore the previous owner when they leave. +Clearing history does not delete journal files. Importing the API requires +neither Qt nor SQLite; entering an opted-in history scope requires SQLite. + +The scope counts only new executor-recorded failures. Those settle the row as +``error`` without replacing action records. Normal break/continue signals keep +the counted outcome; cancellation and raised errors settle as ``error`` and +propagate unchanged. Domain result flags still require caller inspection. +A failed metadata finish cannot replace a pending original signal; its row +may remain unfinished. Manual ownership does not request an error screenshot. + +CLI ``run SCRIPT --history`` uses the default store shown in Run History. +``--history-db PATH`` requires ``--history`` and chooses a bounded custom path; +the CLI closes its custom store. It does not switch the GUI's default store. +Original stdout JSON, variables, dry-run dispatch and failure exit codes remain +unchanged. History previews use source ID ``cli.preview``; ordinary runs use +``cli.run``. Existing journal flags combine with history capture. + +Script Executor (file/editor/directory) and Script Builder offer **Record in +Run History**, initially unchecked, and an optional journal path. The journal +can be used independently of history. A directory invocation has one batch +history owner. These controls wrap the existing execution paths and do not +replace their variable scopes or result display. View the default-store rows +and their links in Run History after execution. + +``AC_history_capture`` accepts named ``body``, optional ``source_id`` and +``script_path``. It keeps the owning executor, strictness and deferred body +interpolation. Its JSON result contains ``history_id``, ``record`` and +``failed_actions``. It is available in Script Builder and through the ordinary +REST/MCP action-execution tools. Outer dry-run previews do not open a history +row for the command. Recording requires ``drive_input`` under RBAC. +``examples/30_manual_history.py`` supports validation without files and a +variable-only run/preview with an explicit output directory. History provenance links diff --git a/docs/source/Zh/doc/cli/cli_doc.rst b/docs/source/Zh/doc/cli/cli_doc.rst index 4eb005705..d4b39d953 100644 --- a/docs/source/Zh/doc/cli/cli_doc.rst +++ b/docs/source/Zh/doc/cli/cli_doc.rst @@ -27,6 +27,22 @@ AutoControl 可以直接從命令列執行自動化腳本。 查閱會再次套用日誌遮罩規則,不修改來源檔。兩個入口皆沿用既有的允許根目錄檢查; 預設範圍以外的位置可透過 ``AUTOCONTROL_ALLOWED_PATH_ROOTS`` 設定。 +明確記錄手動執行歷史 +------------------------ + +.. code-block:: bash + + python -m je_auto_control.cli run script.json --history + python -m je_auto_control.cli run script.json --history --history-db history.sqlite --journal run.jsonl + python -m je_auto_control.cli run script.json --history --dry-run + +``--history`` 在預設 Run History store 建立手動擁有者。``--history-db PATH`` +必須搭配該旗標,沿用允許根目錄檢查,離開時關閉 custom store,不會切換 GUI 的預設 store。 +既有或巢狀 journal 擷取會連結到此擁有者;原本 stdout 紀錄、變數範圍與失敗 exit code +保持不變。executor 記錄的失敗,以及傳遞出的錯誤/取消,會結算為 ``error``。 +預覽使用 ``cli.preview``,實際執行使用 ``cli.run``;沒有選用 history 時,包含僅使用 +journal 的情況,都不會開啟 history 資料庫。 + 執行單一動作檔案 ================ diff --git a/docs/source/Zh/doc/new_features/new_features_doc.rst b/docs/source/Zh/doc/new_features/new_features_doc.rst index f24be149c..f80bb3c01 100644 --- a/docs/source/Zh/doc/new_features/new_features_doc.rst +++ b/docs/source/Zh/doc/new_features/new_features_doc.rst @@ -1370,13 +1370,13 @@ Watcher 在 process 記憶體中追蹤已觸發的 UID,可選擇把訊息標為 GUI: **Email Triggers** 分頁 — 註冊 IMAP 觸發、啟動 / 停止 watcher、 手動觸發一次輪詢、檢視最近錯誤與觸發次數。 -結構化動作日誌(部分介面待接入) +結構化動作日誌(實驗性) ================================ B1 的儲存與明確擷取/查閱 API 已從 ``je_auto_control`` 匯出,也可透過 ``je_auto_control.utils.action_journal`` 使用。CLI 擷取與有界快照查閱已接入; 動作指令、Script Builder 表單、專用 MCP 擷取/查閱工具與 Run History 的唯讀檢視也已 -接入;手動執行的 history 控制仍待接入。B1 整合期間,日誌與來源連結 API +接入;手動執行的 history 控制也已接入。B1 整合期間,日誌與來源連結 API 仍屬實驗性介面。應用程式可明確寫入 schema-v1 快照:: import time @@ -1527,7 +1527,56 @@ RBAC context 啟用時,選檔、來源連結查詢與查閱都要求 ``drive_i Worker 保留提交 context 的 journal 路徑限制,也遵守預設根目錄政策。 遭拒或損壞的來源會顯示錯誤,不會假裝成功讀到空結果。 控制項、表頭與狀態文字支援 GUI 的四種語言。 -手動執行的 history 擁有者接線仍追蹤於 ``Progress.md``。 + + +明確的手動執行歷史 +------------------------ + +``manual_history_scope(store=None, source_id="manual", script_path="")`` +會建立明確要求的手動歷史列,回傳含有 ``store`` 與 ``run_id`` 的 frozen +``HistoryBinding``;兩個名稱都從 ``je_auto_control`` 匯出。``source_id`` 與 +``script_path`` 是非空的來源標籤,不會藉此開啟或執行腳本;已知的解析秘密會在寫入前遮罩。 +呼叫者的 executor、變數與結果值保持原本行為。例如:: + + from je_auto_control import HistoryStore, manual_history_scope, execute_journal_run + + store = HistoryStore("history.sqlite") + try: + with manual_history_scope(store=store, source_id="manual-example") as owner: + result = execute_journal_run( + [["AC_set_var", {"name": "message", "value": "hello"}]], + "events.jsonl", + ) + links = store.journals_for_run(owner.run_id) + finally: + store.close() + +Scope 開始時會連結已存在的 journal;後續開啟的擷取,包含本機 parallel/DAG, +也會保留該擁有者。巢狀手動 scope 各自建立歷史列,離開後還原上一個擁有者。 +清除 history 不刪除外部日誌。匯入 API 不需要 Qt 或 SQLite;明確進入 history scope 時 +才需要 SQLite。 + +Scope 只計算新發生的 executor 記錄失敗,將歷史列結算為 ``error``,不替換動作結果。 +正常 break/continue 保留計數後的結果;取消與丟出的例外會記為 ``error`` 並原樣傳遞。 +領域結果旗標仍由呼叫者檢視。歷史結算失敗不會取代原本正在傳遞的訊號,該列可能保持 +未完成。手動擁有者不要求錯誤截圖。 + +CLI 的 ``run SCRIPT --history`` 使用 Run History 顯示的預設 store。 +``--history-db PATH`` 必須搭配 ``--history``,選用受路徑限制的資料庫;CLI 會關閉自己 +建立的 custom store,但不會切換 GUI 的預設 store。原本 stdout JSON、變數、dry-run +派送與失敗 exit code 保持不變。預覽以 ``cli.preview`` 為來源,一般執行使用 ``cli.run``; +也可搭配既有 journal 旗標。 + +Script Executor(檔案/編輯器/目錄)與 Script Builder 提供預設不勾選的 +**記錄至執行歷史**,以及選填日誌路徑;日誌可獨立於 history 使用。 +目錄呼叫共用一個批次歷史擁有者。控制項包住既有執行流程,不替換變數範圍與結果顯示; +執行後可到 Run History 查看預設 store 的歷史列與日誌連結。 + +``AC_history_capture`` 接受具名 ``body``,以及可選的 ``source_id``、``script_path``, +保留擁有者 executor、strictness 與 body 的延後插值;JSON 結果含 ``history_id``、 +``record``、``failed_actions``。Script Builder 與既有 REST/MCP 動作執行工具都可使用。 +外層 dry-run 不會為該指令建立歷史列;RBAC 下紀錄要求 ``drive_input``。 +``examples/30_manual_history.py`` 支援不寫檔的驗證,以及明確輸出目錄下的純變數執行/預覽。 執行歷史的日誌來源連結 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 5b74754fa..7909c05f8 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -498,3 +498,13 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Visual check**: inspected the rendered 1,280×900 history page and 540-pixel journal view. The offscreen Qt backend initially reported no fonts; loading the existing Segoe UI font in the probe allowed readable layout inspection without changing application fonts or invoking desktop input. - **Files and docs**: journal browser/worker, Run History composition/lifecycle, four translation catalogues, offscreen regressions, three READMEs, journal Sphinx guides, lifecycle/capability references, example instructions, changelog and measured architecture maps. GUI tab/tool/command counts remain unchanged. - **Open items**: B1 manual-run history ownership remains in Progress.md, along with B2/B3 and the broader GUI/worker modernization. Native-device and paid-API acceptance remain deferred at the user's request. + +## U-20261019-06 · 2026-10-19 · Record explicit manual runs across API, CLI and GUI · #done #history #journal + +- **What**: experimental `manual_history_scope` creates an explicit manual owner and yields public `HistoryBinding`, preserving the caller's executor variables and result values. Ambient journals and captures opened later, including local parallel/DAG captures, link to that row. Nested owners restore the parent. Only new executor-counted failures affect the status; normal break/continue preserve the counted outcome, while errors/cancellation settle as error and propagate unchanged. Metadata finish failures cannot replace a pending original signal; missing rows fail explicitly. Known resolved secrets are scrubbed from labels before insertion and refused in new ambient-journal provenance links. Importing the API needs neither Qt nor SQLite; opted-in entry requires SQLite. +- **Entry points**: named-body `AC_history_capture` keeps its owning executor, strictness and one-pass body interpolation, returning history_id/record/failed_actions. It has nested validation, RBAC and Script Builder metadata. CLI `run --history [--history-db PATH]` keeps stdout, variables and exit codes, bounds custom paths and closes only its own store. Preview rows use cli.preview; ordinary rows use cli.run. Script Executor file/editor/directory and Script Builder share initially unchecked controls and an independent optional journal path. Directory execution has one batch owner; default and journal-only paths open no history database. General REST/MCP action execution uses the same command. +- **Corrections**: a broken exception repr no longer replaces the original run error; shared history outcome formatting supplies a type-only fallback. Runtime annotations on the newly public HistoryBinding now resolve without importing SQLite. A legacy journal-link test's synthetic command was renamed to remain a custom probe after the real AC_history_capture registration; its ownership assertions are unchanged. GUI-table row counts were not measured by the old map rewriter: composite sources, package rows and inline filename counts are now regenerated and unknown GUI sources fail visibly. Four new example-count citations also guard both map citations and translated READMEs. +- **Validation**: the final full suite passes 11,938 tests with 69 skipped and 87.83% coverage. 58 new manual API/CLI/command/GUI cases and four new GUI-map cases pass. Missing APIs/controls and five CLI option cases failed before integration; formatter, loop-control, preview-label and runtime-annotation cases separately reproduced their gaps. The focused history/provenance selection passes 75 tests; the adapter/command/interruption selection passes 1,923 with six skipped; final documentation/stub/budget/complexity/Actions-menu gates pass 71, and GUI/lazy-registry/close/complexity checks pass 68. Four core modules pass strict mypy; whole-package three-platform typing has zero errors. Ruff, bandit and whole-package radon pass. The four core modules and new GUI controls score 10.00/10 in pylint with native Qt inspection and repository limits. Whole-library pylint also ran (8.49/10); diagnostics on added lines are generated AC_* stub naming/unused parameters and the CLI's intentional lazy imports, so no clean global lint result is claimed. Strict English/Chinese Sphinx and isolated wheel/source builds pass; all 20 changed production Python/stub sources and the sdist README match the archives. Jeffrey_RPA facade/control suites pass 740 tests against this tree (five existing Discord deprecations). +- **Examples and visual check**: example 30 validates without creating its output directory; actual variable-only execution and preview each produce an ok history row, two corresponding journal snapshots and a provenance link. The 1,000×650 Script Builder render shows the opt-in checkbox and journal path without replacing the editor or result display. Probe fonts are loaded only in the offscreen QA process; no desktop input, native-device acceptance or paid API is invoked. +- **Files and docs**: manual scope/command adapters, public binding imports/exports, CLI, shared GUI controls and two run surfaces, schema/RBAC, outcome formatting, regenerated actions.pyi, four languages, regressions and unique legacy probes, example 30, three READMEs, bilingual CLI/journal references, lifecycle/capability matrix, measured architecture maps and count generators (1,279 facade names, 782 commands, 682 MCP names, 30 examples). +- **Open items**: B2/B3, the broader C–H modernization and shared GUI worker/lifecycle work remain in Progress.md. Native-device and paid-API acceptance remain deferred at the user's request. diff --git a/docs/updates/README.md b/docs/updates/README.md index cf4e257b7..139790f55 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261019-06 | 2026-10-19 | Record explicit manual runs across API, CLI and GUI | #done #history #journal | [2026-10](2026-10.md) | | U-20261019-05 | 2026-10-19 | Browse journal snapshots and history links in Run History | #done #journal #gui | [2026-10](2026-10.md) | | U-20261019-04 | 2026-10-19 | Add observed journal runs and dedicated MCP operations | #done #journal #mcp | [2026-10](2026-10.md) | | U-20261019-03 | 2026-10-19 | Add scoped journal commands and shared safe inspection | #done #journal #executor | [2026-10](2026-10.md) | @@ -387,7 +388,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 51 | +| [2026-10.md](2026-10.md) | 2026-10 | 52 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/examples/30_manual_history.py b/examples/30_manual_history.py new file mode 100644 index 000000000..3024e28ce --- /dev/null +++ b/examples/30_manual_history.py @@ -0,0 +1,41 @@ +"""Validate or preview a variable-only manual history/journal run.""" +import argparse +from dataclasses import asdict +import json +from pathlib import Path + +import je_auto_control as ac +from je_auto_control.utils.executor.action_schema import validate_actions +from je_auto_control.utils.path_guard.path_guard import validate_path + + +def main() -> None: + """Keep the example's database and journal below an explicit output directory.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("--validate", action="store_true", help="validate without writing files") + parser.add_argument("--dry-run", action="store_true", help="record a preview rather than dispatching actions") + args = parser.parse_args() + actions = [["AC_set_var", {"name": "message", "value": "hello"}]] + validate_actions(actions, ac.executor.known_commands()) + if args.validate: + print("validated one variable-only action") + return + folder = validate_path(args.directory) + folder.mkdir(parents=True, exist_ok=True) + store = ac.HistoryStore(folder / "history.sqlite") + try: + source = "example.preview" if args.dry_run else "example" + with ac.manual_history_scope(store=store, source_id=source, script_path=__file__) as owner: + result = ac.execute_journal_run(actions, folder / "events.jsonl", + options=ac.JournalRunOptions(dry_run=args.dry_run)) + row = store.get_run(owner.run_id) + print(json.dumps({"history_id": owner.run_id, "status": row.status if row is not None else None, + "links": [asdict(link) for link in store.journals_for_run(owner.run_id)], + "record": result["record"]}, indent=2)) + finally: + store.close() + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index 560400827..7d774c8fb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -76,3 +76,16 @@ Open Run History and use **Actions → Choose Journal** to select the JSONL file produced by examples 28/29, then **Read Journal**. Existing history rows can supply their provenance links through **Use Journals from Selected Run**. Set a run ID and output limit, select snapshots for details, or cancel the read. + +### 30. Explicit manual history + +[`30_manual_history.py`](30_manual_history.py) records a variable-only run and +links its journal to a custom history database in the supplied directory. +Use `python examples/30_manual_history.py OUTPUT --validate` to check without +writing, or `--dry-run` to record a preview. Normal execution prints the history +ID, status, links and original action record. Configure allowed path roots for +an output directory outside the normal roots. + +CLI `run SCRIPT --history` and the GUI's **Record in Run History** use the +default history store shown in Run History. `--history-db PATH` selects a custom +store for CLI/Python inspection; it does not switch the GUI's default store. diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index eb621a36e..a6dd59439 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -1157,6 +1157,7 @@ from je_auto_control.utils.run_history.history_store import ( HistoryStore, JournalLink, RunRecord, default_history_store, ) +from je_auto_control.utils.run_history import HistoryBinding, manual_history_scope # Structured action provenance (headless, explicit capture only) from je_auto_control.utils.action_journal import ( ActionEvent, ActionJournal, ActionJournalError, JournalBinding, JournalRunOptions, JournalRunResult, @@ -1812,6 +1813,7 @@ def start_autocontrol_gui(*args, **kwargs): "Tracer", "default_tracer", "traced", # Run history "HistoryStore", "JournalLink", "RunRecord", "default_history_store", + "HistoryBinding", "manual_history_scope", "ActionEvent", "ActionJournal", "ActionJournalError", "JournalBinding", "JournalRunOptions", "JournalRunResult", "execute_action_with_journal", "execute_journal_run", "inspect_journal", "journal_scope", "read_events", # Accessibility diff --git a/je_auto_control/actions.pyi b/je_auto_control/actions.pyi index cba229da5..9c2b8ce06 100644 --- a/je_auto_control/actions.pyi +++ b/je_auto_control/actions.pyi @@ -1422,6 +1422,9 @@ def AC_histogram_changed( ) -> Dict[str, Any]: """Adapter: whether the screen / current image differs from a reference.""" +def AC_history_capture(*, body: list[object], source_id: str = ..., script_path: str = ...) -> dict[str, object]: + """Record a named body on its owning executor without eager interpolation.""" + def AC_history_clear() -> int: """Delete every row (and its artifact file); return rows removed.""" diff --git a/je_auto_control/cli.py b/je_auto_control/cli.py index 56a1525bd..f4d0f86a5 100644 --- a/je_auto_control/cli.py +++ b/je_auto_control/cli.py @@ -7,7 +7,7 @@ je_auto_control run script.json [--var x=10 --var y=20] [--dry-run] [--allow-package NAME ...] [--journal run.jsonl] - [--journal-run-id ID] + [--journal-run-id ID] [--history [--history-db history.sqlite]] je_auto_control journal run.jsonl [--run-id ID] [--limit 100] je_auto_control validate script.json # alias: lint je_auto_control list-commands [--filter mouse] [--json] @@ -29,8 +29,8 @@ import sys import threading import time -from contextlib import AbstractContextManager, nullcontext -from typing import Callable, Dict, List, Optional, Sequence +from contextlib import AbstractContextManager, ExitStack, closing, contextmanager, nullcontext +from typing import Callable, Dict, Iterator, List, Optional, Sequence from je_auto_control.utils.logging.secret_scope import scrub_secret_text, secret_log_scope @@ -79,7 +79,8 @@ def cmd_run(args: argparse.Namespace) -> int: variables = _parse_vars(args.var) _allow_packages(args.allow_package) reset_recorded_failures() - with _run_capture(args), execution_scope(variables): + capture = _run_capture(args) + with _run_history(args), capture, execution_scope(variables): # Seeded, not interpolated over the whole tree: that failed on a loop # body's ${item} ("Unknown variable") and would print ${secrets.X} # resolved into the dry-run keys. @@ -95,6 +96,24 @@ def cmd_run(args: argparse.Namespace) -> int: return 0 +@contextmanager +def _run_history(args: argparse.Namespace) -> Iterator[object]: + """Open an opted-in manual owner and close only a CLI-owned custom store.""" + from je_auto_control.utils.run_history import HistoryStore, manual_history_scope + from je_auto_control.utils.path_guard.path_guard import validate_path + path = getattr(args, "history_db", None) + if not getattr(args, "history", False): + if path is not None: + raise ValueError("--history-db requires --history") + yield None + return + with ExitStack() as stack: + store = stack.enter_context(closing(HistoryStore(validate_path(path)))) if path is not None else None + source = "cli.preview" if getattr(args, "dry_run", False) else "cli.run" + owner = stack.enter_context(manual_history_scope(store=store, source_id=source, script_path=args.script)) + yield owner + + def _run_capture(args: argparse.Namespace) -> AbstractContextManager[object]: """Open only an explicitly requested journal, before any action effects.""" from pathlib import Path @@ -294,6 +313,8 @@ def build_parser() -> argparse.ArgumentParser: help="record actions without calling them") p_run.add_argument("--journal", metavar="PATH", help="append structured action snapshots to this journal") p_run.add_argument("--journal-run-id", metavar="ID", help="explicit capture run ID; requires --journal") + p_run.add_argument("--history", action="store_true", help="record this manual run in history") + p_run.add_argument("--history-db", metavar="PATH", help="custom history database; requires --history") p_run.add_argument("--allow-package", action="append", metavar="NAME", help="package AC_add_package_to_executor may load (submodules " "included); may be repeated. JE_AUTOCONTROL_ALLOWED_PACKAGES " diff --git a/je_auto_control/gui/_script_tab.py b/je_auto_control/gui/_script_tab.py index 99a1b90be..cbb4c13de 100644 --- a/je_auto_control/gui/_script_tab.py +++ b/je_auto_control/gui/_script_tab.py @@ -9,6 +9,7 @@ ) from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper +from je_auto_control.gui.manual_run_controls import ManualRunControls, _capture_manual_run from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.executor.action_executor import execute_action, execute_files from je_auto_control.utils.file_process.get_dir_file_list import get_dir_files_as_list @@ -59,6 +60,8 @@ def _build_script_tab(self) -> QWidget: self.script_editor.setPlaceholderText('[["AC_type_keyboard", {"keycode": "a"}]]') layout.addWidget(self.script_editor) + self.script_history_controls = ManualRunControls() + layout.addWidget(self.script_history_controls) layout.addWidget(self._tr(QLabel(), "execution_result")) self.script_result_text = QTextEdit() self.script_result_text.setReadOnly(True) @@ -82,7 +85,9 @@ def _execute_script(self): if not path: return data = read_executable_action_json(path) - result = execute_action(data) + with _capture_manual_run(getattr(self, "script_history_controls", None), + source_id="gui.file", script_path=path): + result = execute_action(data) self.script_result_text.setText(json.dumps(result, indent=2, default=str, ensure_ascii=False)) except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.script_result_text.setText(f"Error: {error}") @@ -98,7 +103,9 @@ def _execute_dir(self): if not path: return files = get_dir_files_as_list(path) - result = execute_files(files) + with _capture_manual_run(getattr(self, "script_history_controls", None), + source_id="gui.directory", script_path=path): + result = execute_files(files) self.script_result_text.setText(json.dumps(result, indent=2, default=str, ensure_ascii=False)) except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.script_result_text.setText(f"Error: {error}") @@ -109,7 +116,9 @@ def _execute_manual_script(self): if not text: return data = json.loads(text) - result = execute_action(data) + with _capture_manual_run(getattr(self, "script_history_controls", None), + source_id="gui.editor", script_path=""): + result = execute_action(data) self.script_result_text.setText(json.dumps(result, indent=2, default=str, ensure_ascii=False)) except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.script_result_text.setText(f"Error: {error}") diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 6993bfaec..30e2d89a7 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -1365,4 +1365,6 @@ "journal_col_run_id": "Run ID", "journal_col_step_id": "Step ID", "journal_col_parent_id": "Parent ID", + "manual_history_record": "Record in Run History", + "manual_journal_optional": "Journal file (optional)", } diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index aee556539..b5cacfc03 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -1252,4 +1252,6 @@ "journal_col_run_id": "実行 ID", "journal_col_step_id": "ステップ ID", "journal_col_parent_id": "親ステップ ID", + "manual_history_record": "実行履歴に記録", + "manual_journal_optional": "ログファイル(省略可)", } diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index c70a00ef9..cd23bfcb8 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -1231,4 +1231,6 @@ "journal_col_run_id": "执行 ID", "journal_col_step_id": "步骤 ID", "journal_col_parent_id": "父步骤 ID", + "manual_history_record": "记录到运行历史", + "manual_journal_optional": "日志文件(可选)", } diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index 5dd671bc1..d2d5c64ec 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -1232,4 +1232,6 @@ "journal_col_run_id": "執行 ID", "journal_col_step_id": "步驟 ID", "journal_col_parent_id": "父步驟 ID", + "manual_history_record": "記錄至執行歷史", + "manual_journal_optional": "日誌檔案(選填)", } diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index 0a967f1fc..568b31ee5 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -347,15 +347,7 @@ def retranslate(self) -> None: self.tabs.setTabText( index, language_wrapper.translate(entry.title_key, entry.title_key), ) - # Widgets registered via TranslatableMixin on this widget (screenshot, - # image-detect, record, script, screen-record, shell, report tabs). - TranslatableMixin.retranslate(self) - if hasattr(self, "_auto_click_retranslate"): - self._auto_click_retranslate() - if hasattr(self, "_screenshot_retranslate"): - self._screenshot_retranslate() - if hasattr(self, "_record_retranslate"): - self._record_retranslate() + self._retranslate_core_tabs() # Child class tabs get their own retranslate if they implement one. for entry in self._built_entries(): callback = getattr(entry.widget, "retranslate", None) @@ -365,6 +357,16 @@ def retranslate(self) -> None: except (RuntimeError, AttributeError): continue + def _retranslate_core_tabs(self) -> None: + """Relabel retained core forms and their optional manual capture controls.""" + TranslatableMixin.retranslate(self) + capture = getattr(self, "script_history_controls", None) + if capture is not None: + capture.retranslate() + for name in ("_auto_click_retranslate", "_screenshot_retranslate", "_record_retranslate"): + if hasattr(self, name): + getattr(self, name)() + def open_script_file(self, path: str) -> None: """Load a JSON script into the Script Executor tab and focus it.""" entry = self._find_entry("script") diff --git a/je_auto_control/gui/manual_run_controls.py b/je_auto_control/gui/manual_run_controls.py new file mode 100644 index 000000000..41e252a55 --- /dev/null +++ b/je_auto_control/gui/manual_run_controls.py @@ -0,0 +1,53 @@ +"""Opted-in history and journal controls shared by manual execution surfaces.""" +from __future__ import annotations + +from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext +from typing import Iterator, Optional + +from PySide6.QtWidgets import QCheckBox, QFormLayout, QLabel, QLineEdit, QVBoxLayout, QWidget + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.utils.action_journal import ActionJournal, journal_scope +from je_auto_control.utils.logging.secret_scope import secret_log_scope +from je_auto_control.utils.path_guard.path_guard import validate_path +from je_auto_control.utils.rbac.policy import authorize_command +from je_auto_control.utils.run_history.manual import manual_history_scope + + +class ManualRunControls(TranslatableMixin, QWidget): + """Inputs only; unchecked history and an empty path preserve existing execution.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self.record_history = QCheckBox() + self.journal_path = QLineEdit() + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._tr(self.record_history, "manual_history_record")) + fields = QFormLayout() + fields.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows) + fields.addRow(self._tr(QLabel(), "manual_journal_optional"), self.journal_path) + layout.addLayout(fields) + + @contextmanager + def capture(self, *, source_id: str, script_path: str) -> Iterator[None]: + """Bind requested ownership/capture around the caller's existing execution.""" + record, path = self.record_history.isChecked(), self.journal_path.text() + if not record and not path: + yield + return + with secret_log_scope(), ExitStack() as stack: + authorize_command("AC_history_capture") + candidate = validate_path(path) if path else None + if record: + stack.enter_context(manual_history_scope(source_id=source_id, script_path=script_path)) + if candidate is not None: + stack.enter_context(journal_scope(ActionJournal(candidate))) + yield + + +def _capture_manual_run(controls: Optional[ManualRunControls], *, source_id: str, + script_path: str) -> AbstractContextManager[object]: + """Leave legacy embedder/stub hosts without the optional controls unchanged.""" + return controls.capture(source_id=source_id, script_path=script_path) if controls is not None else nullcontext() diff --git a/je_auto_control/gui/script_builder/builder_tab.py b/je_auto_control/gui/script_builder/builder_tab.py index 72ba3503a..2b46b409a 100644 --- a/je_auto_control/gui/script_builder/builder_tab.py +++ b/je_auto_control/gui/script_builder/builder_tab.py @@ -10,6 +10,7 @@ ) from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.manual_run_controls import ManualRunControls, _capture_manual_run from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( language_wrapper, ) @@ -44,11 +45,14 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: # The other top-level keys of a loaded {"auto_control": [...]} file, # written back on Save; None for a bare list. self._file_extras: Optional[dict] = None + self._source_path = "" + self._capture = ManualRunControls() self._build_layout() self._wire_signals() def retranslate(self) -> None: TranslatableMixin.retranslate(self) + self._capture.retranslate() if self._add_btn is not None: self._add_btn.setText(_t("sb_add_step")) if hasattr(self._form, "retranslate"): @@ -57,6 +61,7 @@ def retranslate(self) -> None: def _build_layout(self) -> None: root = QVBoxLayout(self) root.addLayout(self._build_toolbar()) + root.addWidget(self._capture) splitter = QSplitter(Qt.Orientation.Horizontal) splitter.addWidget(self._tree) splitter.addWidget(self._form) @@ -130,6 +135,7 @@ def _on_save(self) -> None: return try: save_action_file(path, self._tree.root_steps(), self._file_extras) + self._source_path = path self._result.setPlainText(f"Saved: {path}") except (AutoControlException, OSError, ValueError, TypeError) as error: QMessageBox.warning(self, "Error", str(error)) @@ -142,6 +148,7 @@ def _on_load(self) -> None: return try: steps, self._file_extras = load_action_file(path) + self._source_path = path self._tree.load_steps(steps) self._form.load_step(None) self._result.setPlainText(f"Loaded: {path}") @@ -154,7 +161,9 @@ def _on_run(self) -> None: if not actions: QMessageBox.information(self, "Info", "No steps to run") return - result = execute_action(actions) + with _capture_manual_run(getattr(self, "_capture", None), source_id="gui.builder", + script_path=getattr(self, "_source_path", "")): + result = execute_action(actions) self._result.setPlainText( json.dumps(result, indent=2, default=str, ensure_ascii=False) ) diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 8bf02a050..2fb3f94f8 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -76,6 +76,13 @@ def _build_specs() -> List[CommandSpec]: def _add_journal_specs(specs: List[CommandSpec]) -> None: + specs.append(CommandSpec( + "AC_history_capture", "Flow", "Record Manual Run History", + fields=(FieldSpec("source_id", FieldType.STRING, optional=True, default="script"), + FieldSpec("script_path", FieldType.STRING, optional=True, default="")), + body_keys=("body",), + description="Record the nested body without replacing its executor or variables; labels do not open files.", + )) path = FieldSpec("path", FieldType.FILE_PATH) run_id = FieldSpec("run_id", FieldType.STRING, optional=True) specs.append(CommandSpec( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index a45f61b96..22b45cbd3 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -37,6 +37,7 @@ from je_auto_control.utils.action_journal.commands import _capture_body, _read_journal from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted +from je_auto_control.utils.run_history.commands import _history_capture_body from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS from je_auto_control.utils.llm.planner import ( plan_actions as llm_plan_actions, @@ -7495,6 +7496,7 @@ def __init__(self): "AC_check_catalog": _check_catalog, "AC_run_resumable": _run_resumable, "AC_journal_capture": _capture_body, + "AC_history_capture": _history_capture_body, "AC_journal_read": _read_journal, "AC_checkpoint_status": _checkpoint_status, "AC_checkpoint_clear": _checkpoint_clear, diff --git a/je_auto_control/utils/executor/action_schema.py b/je_auto_control/utils/executor/action_schema.py index beadc480b..c3dd7e73b 100644 --- a/je_auto_control/utils/executor/action_schema.py +++ b/je_auto_control/utils/executor/action_schema.py @@ -17,6 +17,7 @@ # Keys whose value is a flat action list: [[name, params], ...] FLOW_BODY_KEYS = { + "AC_history_capture": ("body",), "AC_journal_capture": ("body",), "AC_if_image_found": ("then", "else"), "AC_if_pixel": ("then", "else"), diff --git a/je_auto_control/utils/rbac/policy.py b/je_auto_control/utils/rbac/policy.py index 568871074..bcaca8d65 100644 --- a/je_auto_control/utils/rbac/policy.py +++ b/je_auto_control/utils/rbac/policy.py @@ -72,6 +72,7 @@ #: Commands that need more than ``drive_input``. Every other command is #: covered by the capability of the surface that submitted the action list. COMMAND_CAPABILITIES: Dict[str, str] = { + "AC_history_capture": _DRIVE, "AC_journal_capture": _DRIVE, "AC_journal_read": _DRIVE, "AC_sign_action_file": Capability.SIGN_ACTIONS, diff --git a/je_auto_control/utils/run_history/__init__.py b/je_auto_control/utils/run_history/__init__.py index fc26b7bae..feb5082c5 100644 --- a/je_auto_control/utils/run_history/__init__.py +++ b/je_auto_control/utils/run_history/__init__.py @@ -2,5 +2,9 @@ from je_auto_control.utils.run_history.history_store import ( HistoryStore, JournalLink, RunRecord, default_history_store, ) +from je_auto_control.utils.run_history.context import HistoryBinding +from je_auto_control.utils.run_history.manual import manual_history_scope -__all__ = ["HistoryStore", "JournalLink", "RunRecord", "default_history_store"] +__all__ = [ + "HistoryStore", "JournalLink", "RunRecord", "default_history_store", "HistoryBinding", "manual_history_scope", +] diff --git a/je_auto_control/utils/run_history/commands.py b/je_auto_control/utils/run_history/commands.py new file mode 100644 index 000000000..1281f2342 --- /dev/null +++ b/je_auto_control/utils/run_history/commands.py @@ -0,0 +1,16 @@ +"""Named-body executor adapter for explicit manual run-history ownership.""" +from je_auto_control.utils.executor.action_schema import validate_actions +from je_auto_control.utils.run_history.manual import manual_history_scope + + +def _history_capture_body(*, body: list[object], source_id: str = "script", + script_path: str = "") -> dict[str, object]: + """Record a named body on its owning executor without eager interpolation.""" + # pylint: disable-next=import-outside-toplevel # reason: the executor registers this adapter + from je_auto_control.utils.executor.action_executor import _running_executor, recorded_failures + runner = _running_executor() + validate_actions(body, runner.known_commands()) + with manual_history_scope(source_id=source_id, script_path=script_path) as owner: + before = recorded_failures() + record: dict[str, object] = dict(runner.execute_action(body, _validated=True)) + return {"history_id": owner.run_id, "record": record, "failed_actions": recorded_failures() - before} diff --git a/je_auto_control/utils/run_history/context.py b/je_auto_control/utils/run_history/context.py index 41b2430b1..e46ed6616 100644 --- a/je_auto_control/utils/run_history/context.py +++ b/je_auto_control/utils/run_history/context.py @@ -4,10 +4,9 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass -from typing import TYPE_CHECKING, Iterator, Optional +from typing import Iterator, Optional -if TYPE_CHECKING: - from je_auto_control.utils.run_history.history_store import HistoryStore +from je_auto_control.utils.run_history.history_store import HistoryStore @dataclass(frozen=True) diff --git a/je_auto_control/utils/run_history/manual.py b/je_auto_control/utils/run_history/manual.py new file mode 100644 index 000000000..429dc0b2d --- /dev/null +++ b/je_auto_control/utils/run_history/manual.py @@ -0,0 +1,81 @@ +"""Explicit manual execution ownership without changing executor results or variables.""" +from contextlib import contextmanager +from typing import Iterator, Optional + +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.logging.secret_scope import scrub_secret_text, secret_log_scope +from je_auto_control.utils.rbac.policy import authorize_command +from je_auto_control.utils.run_history.context import HistoryBinding, bound_history +from je_auto_control.utils.run_history.history_store import ( + HistoryStore, HistoryStoreError, SOURCE_MANUAL, STATUS_ERROR, default_history_store, +) +from je_auto_control.utils.run_history.run_outcome import RunOutcome + + +@contextmanager +def manual_history_scope(*, store: Optional[HistoryStore] = None, source_id: str = "manual", + script_path: str = "") -> Iterator[HistoryBinding]: + """Record an explicitly requested manual run, preserving the caller's execution scope. + + The yielded binding identifies one new history row. Existing and nested + journals link to it; leaving restores the previous owner. Executor-counted + failures mark the row as error without changing results or raising a new + exception. Normal break/continue signals retain the counted outcome. + Domain result flags remain the caller's responsibility. No + screenshot is requested. SQLite is needed only when this scope is entered. + """ + # pylint: disable-next=import-outside-toplevel # reason: the executor imports manual command adapters + from je_auto_control.utils.executor.action_executor import recorded_failures + # pylint: disable-next=import-outside-toplevel # reason: flow control imports the capture/history boundary + from je_auto_control.utils.executor.flow_control import LoopBreak, LoopContinue + with secret_log_scope(): + authorize_command("AC_history_capture") + for value in (source_id, script_path): + if not isinstance(value, str) or not value: + raise HistoryStoreError("manual history labels must be non-empty strings") + owner = default_history_store if store is None else store + identity = owner.start_run(SOURCE_MANUAL, scrub_secret_text(source_id), scrub_secret_text(script_path)) + binding = HistoryBinding(owner, identity) + outcome, error, before = RunOutcome(), None, recorded_failures() + with bound_history(binding): + try: + _link_current_journal(binding) + yield binding + except BaseException as failure: # reason: record and re-raise the exact original control signal + error = failure + if not isinstance(failure, (LoopBreak, LoopContinue)): + outcome.fail(failure) + raise + finally: + failures = recorded_failures() - before + if failures > 0 and outcome.status != STATUS_ERROR: + outcome.status = STATUS_ERROR + outcome.error_text = f"{failures} action(s) failed; see this run's record" + _finish(binding, outcome, error) + + +def _link_current_journal(owner: HistoryBinding) -> None: + """Include a capture that was opened before its new manual history owner.""" + # pylint: disable-next=import-outside-toplevel # reason: capture imports the history package during facade setup + from je_auto_control.utils.action_journal.capture import current_journal + capture = current_journal() + if capture is None: + return + path = str(capture.journal.path.resolve()) + if any(scrub_secret_text(value) != value for value in (path, capture.run_id)): + raise HistoryStoreError("active journal provenance cannot contain a resolved secret") + for policy in capture.path_policies: + policy.validate(path, operation="manual history journal link") + if not owner.store.attach_journal(owner.run_id, path, capture.run_id): + raise HistoryStoreError("cannot attach journal to a missing manual history run") + + +def _finish(owner: HistoryBinding, outcome: RunOutcome, error: Optional[BaseException]) -> None: + """Preserve a pending original failure if final metadata cannot be committed.""" + try: + if not owner.store.finish_run(owner.run_id, outcome.status, outcome.error_text): + raise HistoryStoreError("cannot finish a missing manual history run") + except BaseException: # pylint: disable=broad-exception-caught # reason: retain the pending original signal + if error is None: + raise + autocontrol_logger.error("manual history completion unavailable; preserving original exception") diff --git a/je_auto_control/utils/run_history/run_outcome.py b/je_auto_control/utils/run_history/run_outcome.py index acef41568..cf346ec79 100644 --- a/je_auto_control/utils/run_history/run_outcome.py +++ b/je_auto_control/utils/run_history/run_outcome.py @@ -50,7 +50,7 @@ def fail(self, error: BaseException) -> None: self.hard_abort = not isinstance(error, Exception) self.cancelled = is_run_interruption(error) self.error_text = (f"{type(error).__name__}: run interrupted" if self.hard_abort or self.cancelled - else repr(error)) + else _ordinary_error_text(error)) @property def should_capture(self) -> bool: @@ -58,6 +58,14 @@ def should_capture(self) -> bool: return self.status == STATUS_ERROR and not self.hard_abort and not self.cancelled +def _ordinary_error_text(error: BaseException) -> str: + """Keep a broken exception formatter from replacing the original run failure.""" + try: + return repr(error) + except Exception: # pylint: disable=broad-exception-caught # reason: preserve the original failure + return f"{type(error).__name__}: exception detail unavailable" + + def finish_history_run(store: HistoryStore, run_id: int, outcome: RunOutcome, capture: Callable[[int], Optional[str]]) -> None: """Settle history while preserving any hard interruption already propagating.""" diff --git a/test/unit_test/headless/test_doc_counts.py b/test/unit_test/headless/test_doc_counts.py index da766a986..8cc126df4 100644 --- a/test/unit_test/headless/test_doc_counts.py +++ b/test/unit_test/headless/test_doc_counts.py @@ -89,6 +89,10 @@ def _typing_exemptions() -> int: ("README.md", r"all (\d+) commands", "AC_* commands", _commands), ("README.md", r"(\d+) tools for", "MCP tools", _mcp_tools), ("README.md", r"(\d+) self-contained scripts", "examples", _examples), + ("architecture_explore.md", r"範例腳本 \| (\d+) \|", "examples", _examples), + ("architecture_explore.md", r"`examples/` \| (\d+) 個腳本", "examples", _examples), + ("README/README_zh-CN.md", r"(\d+) 个自包含脚本", "examples", _examples), + ("README/README_zh-TW.md", r"(\d+) 個自足腳本", "examples", _examples), ("README/README_zh-CN.md", r"(\d+) 个 `AC_\*` 命令", "AC_* commands", _commands), ("README/README_zh-CN.md", r"全部 (\d+) 个命令", "AC_* commands", _commands), diff --git a/test/unit_test/headless/test_doc_line_counts.py b/test/unit_test/headless/test_doc_line_counts.py index cee7dcacc..861caf1e4 100644 --- a/test/unit_test/headless/test_doc_line_counts.py +++ b/test/unit_test/headless/test_doc_line_counts.py @@ -23,7 +23,7 @@ import pathlib import re import sys -from typing import Dict, List, Optional, Tuple +from typing import Dict, Iterator, List, Optional, Tuple ROOT = pathlib.Path(__file__).resolve().parents[3] PACKAGE = ROOT / "je_auto_control" @@ -59,6 +59,8 @@ _BLOCKQUOTE = re.compile(r"^> (\d[\d,]*) 個套件、約 (\d[\d,]*) 行。") _METRIC_TOTAL_LINES = re.compile(r"^(\| 程式碼總行數 \| )([\d,]+)( \|)") _METRIC_TOTAL_FILES = re.compile(r"^(\| Python 模組總數(含周邊子專案) \| )([\d,]+)( \|)") +_GUI_TABLE_HEADER = "| 分頁 | 模組 | 行數 | 職責 |" +_GUI_INLINE_COUNT = re.compile(r"(`([^`]+\.py)`()([\d,]+)( 行)") # §8 rows that are not a plain path measurement. _TOP_LEVEL_ROW = "je_auto_control/" # 頂層 3 檔 — not recursive @@ -270,6 +272,47 @@ def _rewrite_table_rows(doc_lines: List[str]) -> Dict[str, object]: return derived +def _gui_table_rows(doc_lines: List[str]) -> Iterator[int]: + """Locate GUI rows whose source paths live in their second cell.""" + active = False + for index, line in enumerate(doc_lines): + cells = line.split("|") + if line.rstrip() == _GUI_TABLE_HEADER: + active = True + elif not line.startswith("|"): + active = False + elif active and len(cells) >= 5 and _TICKED.search(cells[2]): + yield index + + +def _gui_inline_counts(caption: str, context: str) -> str: + """Measure filename/count pairs in a GUI row's description as well.""" + def replace(match: re.Match[str]) -> str: + target = _resolve(match.group(2), context) + if target is None: + raise ValueError(f"unknown GUI map source: {match.group(2)}") + return f"{match.group(1)}{_measure(target)[1]:,}{match.group(4)}" + return _GUI_INLINE_COUNT.sub(replace, caption) + + +def _rewrite_gui_tables(doc_lines: List[str]) -> None: + """Measure individual, composite and directory-backed GUI tab rows.""" + for index in _gui_table_rows(doc_lines): + cells = doc_lines[index].split("|") + targets = [] + for name in _TICKED.findall(cells[2]): + target = _resolve(name, "gui") + if target is None: + raise ValueError(f"unknown GUI map source: {name}") + targets.append(target) + files = {path for target in targets + for path in (_py_files(target) if target.is_dir() else [target])} + cells[3] = f" {sum(_lines(path) for path in files):,} " + first = targets[0] if targets[0].is_dir() else targets[0].parent + cells[4] = _gui_inline_counts(cells[4], first.relative_to(PACKAGE).as_posix()) + doc_lines[index] = "|".join(cells) + + def _rewrite_slashed_row(line: str, context: Optional[str]) -> str: """Rewrite a row that quotes one figure per file: ``82 / 78``. @@ -334,6 +377,7 @@ def rewrite(text: str) -> str: """Return ``text`` with every measurable figure replaced by a measurement.""" doc_lines = text.split("\n") _rewrite_appendix_totals(doc_lines, _rewrite_table_rows(doc_lines)) + _rewrite_gui_tables(doc_lines) _rewrite_headings(doc_lines) _rewrite_scope_metrics(doc_lines) return "\n".join(doc_lines) @@ -389,6 +433,10 @@ def unresolved_rows() -> List[str]: for each in names: if _resolve(each, context) is None: missing.append(f"line {index + 1}: {each}") + for index in _gui_table_rows(doc_lines): + for name in _TICKED.findall(doc_lines[index].split("|")[2]): + if _resolve(name, "gui") is None: + missing.append(f"line {index + 1}: {name}") return missing diff --git a/test/unit_test/headless/test_gui_map_measurements.py b/test/unit_test/headless/test_gui_map_measurements.py new file mode 100644 index 000000000..eae2a3b59 --- /dev/null +++ b/test/unit_test/headless/test_gui_map_measurements.py @@ -0,0 +1,39 @@ +"""GUI map rows must measure their actual module lists and inline source counts.""" +import pytest + +from headless import test_doc_line_counts as measure + + +_HEADER = "| 分頁 | 模組 | 行數 | 職責 |" + + +def _document(row): + return "### 5.5 GUI `gui/`\n\n" + _HEADER + "\n| --- | --- | ---: | --- |\n" + row + "\n" + + +def test_composite_gui_row_is_measured_from_every_source(): + row = "| run_history | `run_history_tab.py` + `run_history_timeline.py` | 1 | history |" + expected = sum(measure._lines(measure.PACKAGE / "gui" / name) + for name in ("run_history_tab.py", "run_history_timeline.py")) + result = measure.rewrite(_document(row)) + assert f"| {expected:,} | history |" in result + + +def test_gui_package_caption_and_total_use_the_same_tree(): + row = "| script_builder | `script_builder/` | 1 | `command_schema.py`(1 行 `AC_*` 參數綱要) |" + folder = measure.PACKAGE / "gui" / "script_builder" + result = measure.rewrite(_document(row)) + assert f"| {measure._measure(folder)[1]:,} |" in result + assert f"`command_schema.py`({measure._lines(folder / 'command_schema.py'):,} 行" in result + + +def test_unknown_gui_source_cannot_silently_escape_measurement(): + with pytest.raises(ValueError, match="no_such_gui_file"): + measure.rewrite(_document("| missing | `no_such_gui_file.py` | 1 | missing |")) + + +def test_unresolved_rows_reports_gui_sources(tmp_path, monkeypatch): + document = tmp_path / "map.md" + document.write_text(_document("| missing | `no_such_gui_file.py` | 1 | missing |"), encoding="utf-8") + monkeypatch.setattr(measure, "DOC", document) + assert any("no_such_gui_file.py" in item for item in measure.unresolved_rows()) diff --git a/test/unit_test/headless/test_journal_history_links.py b/test/unit_test/headless/test_journal_history_links.py index c566d0a31..4637e6b6b 100644 --- a/test/unit_test/headless/test_journal_history_links.py +++ b/test/unit_test/headless/test_journal_history_links.py @@ -164,9 +164,9 @@ def test_workers_keep_the_parent_history_when_they_start_their_own_journal(store def capture(index): with journal_scope(ActionJournal(tmp_path / f"{index}.jsonl"), run_id=f"child-{index}"): executor.execute_action([["AC_history_inner"]]) - monkeypatch.setitem(executor.event_dict, "AC_history_capture", capture) + monkeypatch.setitem(executor.event_dict, "AC_history_link_capture_probe", capture) monkeypatch.setitem(executor.event_dict, "AC_history_inner", lambda: None) - bodies = [[["AC_history_capture", {"index": index}]] for index in range(2)] + bodies = [[["AC_history_link_capture_probe", {"index": index}]] for index in range(2)] script = ([["AC_parallel", {"branches": bodies}]] if kind == "parallel" else [["AC_run_dag", {"definition": {"nodes": [ {"id": str(index), "actions": body} for index, body in enumerate(bodies)]}}]]) @@ -187,10 +187,10 @@ def test_background_run_owners_link_explicit_journals_to_their_existing_row(kind def capture(): with journal_scope(ActionJournal(path), run_id="background"): executor.execute_action([["AC_history_inner"]]) - monkeypatch.setitem(executor.event_dict, "AC_history_capture", capture) + monkeypatch.setitem(executor.event_dict, "AC_history_link_capture_probe", capture) monkeypatch.setitem(executor.event_dict, "AC_history_inner", lambda: None) script = tmp_path / "actions.json" - script.write_text(json.dumps([["AC_history_capture"]]), encoding="utf-8") + script.write_text(json.dumps([["AC_history_link_capture_probe"]]), encoding="utf-8") if kind == "scheduler": owner = scheduler.Scheduler() job = owner.add_job(str(script), 60) diff --git a/test/unit_test/headless/test_manual_history.py b/test/unit_test/headless/test_manual_history.py new file mode 100644 index 000000000..fccebec43 --- /dev/null +++ b/test/unit_test/headless/test_manual_history.py @@ -0,0 +1,228 @@ +"""Explicit manual owners preserve results, signals, counters and journal provenance.""" +import asyncio +from concurrent.futures import CancelledError +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.run_history import manual as manual_module +from je_auto_control.utils.run_history.context import HistoryBinding, bound_history, current_history +from je_auto_control.utils.run_history.history_store import HistoryStoreError +from je_auto_control.utils.executor.action_executor import executor, recorded_failures +from je_auto_control.utils.logging.secret_scope import remember_secret, secret_log_scope +from je_auto_control.utils.mcp_server.context import OperationCancelledError +from je_auto_control.utils.rbac import AuthorizationContext, Role, authorization_scope +from je_auto_control.utils.script_vars.execution import execution_scope + + +@pytest.fixture +def store(tmp_path): + value = ac.HistoryStore(tmp_path / "history.sqlite") + yield value + value.close() + + +def test_scope_preserves_existing_variables_results_and_owner(store): + assert ac.manual_history_scope is manual_module.manual_history_scope + with execution_scope({"message": "before"}): + with ac.manual_history_scope(store=store, source_id="editor", script_path="") as binding: + assert isinstance(binding, ac.HistoryBinding) and current_history() is binding + result = executor.execute_action([["AC_set_var", {"name": "message", "value": "after"}]]) + assert executor.variables.get("message") == "after" and result + row = store.get_run(binding.run_id) + assert row.source_type == "manual" and row.source_id == "editor" and row.script_path == "" + assert row.status == "ok" and row.finished_at is not None and row.artifact_path is None + assert current_history() is None + + +def test_scope_counts_only_its_own_soft_failures(store, monkeypatch): + def fail(): + raise ValueError("controlled failure") + monkeypatch.setitem(executor.event_dict, "AC_manual_failure", fail) + executor.execute_action([["AC_manual_failure"]]) + before = recorded_failures() + with ac.manual_history_scope(store=store) as clean: + executor.execute_action([["AC_set_var", {"name": "x", "value": 1}]]) + assert store.get_run(clean.run_id).status == "ok" and recorded_failures() == before + with ac.manual_history_scope(store=store) as failed: + result = executor.execute_action([["AC_manual_failure"]]) + assert any("controlled failure" in str(value) for value in result.values()) + assert store.get_run(failed.run_id).status == "error" + assert recorded_failures() == before + 1 + + +_SIGNALS = [ValueError("original"), KeyboardInterrupt(), SystemExit(7), GeneratorExit(), + asyncio.CancelledError(), CancelledError(), OperationCancelledError("cancelled")] + + +@pytest.mark.parametrize("signal", _SIGNALS, ids=lambda signal: type(signal).__name__) +def test_original_signal_is_retained_and_history_is_error(store, signal): + with pytest.raises(type(signal)) as caught: + with ac.manual_history_scope(store=store) as binding: + raise signal + assert caught.value is signal and current_history() is None + assert store.get_run(binding.run_id).status == "error" + + +@pytest.mark.parametrize("signal", _SIGNALS, ids=lambda signal: type(signal).__name__) +def test_failed_completion_does_not_replace_pending_signal(store, monkeypatch, signal): + def fail(*args, **kwargs): + raise HistoryStoreError("completion unavailable") + monkeypatch.setattr(store, "finish_run", fail) + with pytest.raises(type(signal)) as caught: + with ac.manual_history_scope(store=store) as binding: + raise signal + assert caught.value is signal and store.get_run(binding.run_id).status == "running" + + +def test_failed_completion_without_original_signal_propagates(store, monkeypatch): + monkeypatch.setattr(store, "finish_run", lambda *a, **k: False) + with pytest.raises(HistoryStoreError, match="missing"): + with ac.manual_history_scope(store=store): + pass + + +def test_start_failure_has_no_body_effect_and_restores_parent(store, monkeypatch): + parent = HistoryBinding(store, store.start_run("manual", "parent", "script")) + def fail(*args, **kwargs): + raise HistoryStoreError("start unavailable") + monkeypatch.setattr(store, "start_run", fail) + with bound_history(parent): + with pytest.raises(HistoryStoreError): + with ac.manual_history_scope(store=store): + pytest.fail("body executed after failed start") + assert current_history() is parent + + +def test_nested_owners_restore_and_both_observe_body_failure(store, monkeypatch): + def fail(): + raise ValueError("controlled") + monkeypatch.setitem(executor.event_dict, "AC_manual_failure", fail) + with ac.manual_history_scope(store=store, source_id="outer") as outer: + with ac.manual_history_scope(store=store, source_id="inner") as inner: + executor.execute_action([["AC_manual_failure"]]) + assert current_history() is outer + assert outer.run_id != inner.run_id + assert [store.get_run(owner.run_id).status for owner in (outer, inner)] == ["error", "error"] + + +@pytest.mark.parametrize("order", ["history-first", "journal-first"]) +def test_existing_and_new_journals_are_linked(store, tmp_path, order): + path = tmp_path / "events.jsonl" + if order == "history-first": + with ac.manual_history_scope(store=store) as owner: + ac.execute_journal_run([["AC_set_var", {"name": "x", "value": 1}]], path, + options=ac.JournalRunOptions(run_id="journal")) + else: + with ac.journal_scope(ac.ActionJournal(path), run_id="journal"): + with ac.manual_history_scope(store=store) as owner: + executor.execute_action([["AC_set_var", {"name": "x", "value": 1}]]) + assert store.journals_for_run(owner.run_id) == [ac.JournalLink(owner.run_id, str(path.resolve()), "journal")] + assert len(ac.read_events(path)) == 2 + store.clear() + assert path.exists() + + +@pytest.mark.parametrize("worker", ["parallel", "dag"]) +def test_worker_captures_link_to_the_manual_owner(store, tmp_path, worker): + path = tmp_path / "worker.jsonl" + body = [["AC_journal_capture", {"path": str(path), "body": [["AC_set_var", {"name": "x", "value": 1}]]}]] + if worker == "parallel": + actions = [["AC_parallel", {"branches": [body]}]] + else: + actions = [["AC_run_dag", {"definition": {"nodes": [{"id": "one", "actions": body}]}}]] + with ac.manual_history_scope(store=store) as owner: + executor.execute_action(actions, raise_on_error=True) + assert len(store.journals_for_run(owner.run_id)) == 1 and path.exists() + + +def test_metadata_and_failure_text_are_masked_before_sqlite(store): + secret = "manual-private-marker" + with secret_log_scope(): + remember_secret(secret) + with pytest.raises(ValueError): + with ac.manual_history_scope(store=store, source_id=secret, script_path=secret) as owner: + raise ValueError(secret) + row = store.get_run(owner.run_id) + assert row.source_id == row.script_path == "***" and secret not in row.error_text + + +def test_ambient_journal_policy_is_checked_before_linking(store, tmp_path): + policy = ac.PathPolicy(use_client_roots=True) + policy.set_client_roots([tmp_path]) + with ac.journal_scope(ac.ActionJournal(tmp_path / "events.jsonl"), path_policy=policy): + policy.set_client_roots([tmp_path / "elsewhere"]) + with pytest.raises(ac.PathNotAllowedError): + with ac.manual_history_scope(store=store): + pytest.fail("body executed outside active roots") + assert store.list_runs()[0].status == "error" and store.journals_for_run(store.list_runs()[0].id) == [] + + +def test_viewer_is_refused_before_store_effects(store, monkeypatch): + def fail(*args, **kwargs): + pytest.fail("viewer created history") + monkeypatch.setattr(store, "start_run", fail) + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + with pytest.raises(ac.AuthorizationError): + with ac.manual_history_scope(store=store): + pass + + +def test_import_without_sqlite_or_qt_opens_no_database(tmp_path): + probe = """ +import importlib.abc, sys +class Block(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in {'sqlite3', 'PySide6'}: + raise ImportError('blocked for controlled import') +sys.meta_path.insert(0, Block()) +import je_auto_control as ac +assert callable(ac.manual_history_scope) +assert not any(name.startswith(('sqlite3', 'PySide6')) for name in sys.modules) +print('ok') +""" + done = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, timeout=120, + env=dict(os.environ, PYTHONPATH=str(Path.cwd())), check=False) + assert done.returncode == 0, done.stderr + assert done.stdout.strip() == "ok" + + +def test_existing_journal_secret_metadata_is_refused_before_link(store, tmp_path): + secret = "manual-secret-path" + with secret_log_scope(): + with ac.journal_scope(ac.ActionJournal(tmp_path / (secret + ".jsonl")), run_id="ambient"): + remember_secret(secret) + with pytest.raises(HistoryStoreError, match="resolved secret"): + with ac.manual_history_scope(store=store): + pytest.fail("unsafe provenance reached body") + row = store.list_runs()[0] + assert row.status == "error" and store.journals_for_run(row.id) == [] + assert secret not in row.error_text + + +@pytest.mark.parametrize("field,value", [("source_id", ""), ("source_id", None), ("script_path", "")]) +def test_invalid_labels_have_no_store_effects(store, field, value): + with pytest.raises(HistoryStoreError): + with ac.manual_history_scope(store=store, **{field: value}): + pytest.fail("invalid labels executed") + assert store._conn is None + + +def test_error_formatting_does_not_replace_the_original_failure(store): + class BrokenError(ac.AutoControlException): + def __repr__(self): + raise ValueError("failed to format error") + original = BrokenError("original") + with pytest.raises(BrokenError) as caught: + with ac.manual_history_scope(store=store) as owner: + raise original + assert caught.value is original and store.get_run(owner.run_id).status == "error" + + +def test_public_binding_annotations_resolve_at_runtime(): + from typing import get_type_hints + assert get_type_hints(ac.HistoryBinding)["store"] is ac.HistoryStore diff --git a/test/unit_test/headless/test_manual_history_entrypoints.py b/test/unit_test/headless/test_manual_history_entrypoints.py new file mode 100644 index 000000000..b8900058a --- /dev/null +++ b/test/unit_test/headless/test_manual_history_entrypoints.py @@ -0,0 +1,183 @@ +"""Manual CLI and action commands opt in without changing result payloads.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.cli import main +from je_auto_control.utils.executor.action_executor import Executor, executor +from je_auto_control.utils.run_history import manual as manual_module +from je_auto_control.utils.rbac import AuthorizationContext, Role, authorization_scope + + +@pytest.fixture +def store(tmp_path, monkeypatch): + value = ac.HistoryStore(tmp_path / "history.sqlite") + monkeypatch.setattr(manual_module, "default_history_store", value) + yield value + value.close() + + +def _script(tmp_path, actions): + path = tmp_path / "script.json" + path.write_text(json.dumps(actions), encoding="utf-8") + return str(path) + + +def test_cli_history_and_journal_preserve_stdout_and_link(tmp_path, store, capsys): + script = _script(tmp_path, [["AC_set_var", {"name": "message", "value": "${seed}"}]]) + journal = tmp_path / "events.jsonl" + assert main(["run", script, "--history", "--var", "seed=hello", "--journal", str(journal)]) == 0 + payload = json.loads(capsys.readouterr().out) + assert "history_id" not in payload and "journal_path" not in payload + rows = store.list_runs() + assert len(rows) == 1 and rows[0].source_type == "manual" and rows[0].script_path == script + assert rows[0].status == "ok" and len(store.journals_for_run(rows[0].id)) == 1 + + +def test_cli_custom_history_database_is_explicit(tmp_path, store, capsys): + script = _script(tmp_path, [["AC_set_var", {"name": "x", "value": 1}]]) + custom = tmp_path / "custom.sqlite" + assert main(["run", script, "--history", "--history-db", str(custom)]) == 0 + capsys.readouterr() + check = ac.HistoryStore(custom) + try: + assert check.list_runs()[0].status == "ok" and store.list_runs() == [] + finally: + check.close() + + +def test_cli_disabled_history_uses_no_store(tmp_path, store, capsys, monkeypatch): + def fail(*args, **kwargs): + pytest.fail("history was not requested") + monkeypatch.setattr(store, "start_run", fail) + script = _script(tmp_path, [["AC_set_var", {"name": "x", "value": 1}]]) + assert main(["run", script]) == 0 + capsys.readouterr() + assert store._conn is None + + +def test_cli_history_failure_keeps_exit_code_and_result(tmp_path, store, capsys, monkeypatch): + def fail(): + raise ValueError("controlled") + monkeypatch.setitem(executor.event_dict, "AC_manual_cli_fail", fail) + script = _script(tmp_path, [["AC_manual_cli_fail"]]) + assert main(["run", script, "--history"]) == 1 + output = capsys.readouterr() + assert json.loads(output.out) and "1 action(s) failed" in output.err + assert store.list_runs()[0].status == "error" + + +def test_cli_history_preview_has_no_action_effects(tmp_path, store, capsys, monkeypatch): + def fail(): + pytest.fail("preview dispatched") + monkeypatch.setitem(executor.event_dict, "AC_manual_cli_preview", fail) + script = _script(tmp_path, [["AC_manual_cli_preview"]]) + assert main(["run", script, "--history", "--dry-run"]) == 0 + assert "dry-run" in capsys.readouterr().out and store.list_runs()[0].status == "ok" + assert store.list_runs()[0].source_id == "cli.preview" + + +def test_cli_history_database_requires_opt_in(tmp_path, store, capsys, monkeypatch): + effects = [] + monkeypatch.setitem(executor.event_dict, "AC_manual_cli_probe", lambda: effects.append(1)) + script = _script(tmp_path, [["AC_manual_cli_probe"]]) + assert main(["run", script, "--history-db", str(tmp_path / "unused.sqlite")]) == 1 + assert "requires --history" in capsys.readouterr().err and effects == [] and store._conn is None + + +def test_named_history_body_defers_values_and_keeps_private_executor(store): + private = Executor() + private.variables.set("message", "before") + body = [["AC_set_var", {"name": "message", "value": "after"}], ["AC_get_var", {"name": "message"}]] + result = private.execute_action([["AC_history_capture", {"body": body, "source_id": "nested"}]], + raise_on_error=True) + summary = next(iter(result.values())) + assert private.variables.get("message") == "after" and "after" in summary["record"].values() + assert store.get_run(summary["history_id"]).status == "ok" and summary["failed_actions"] == 0 + + +def test_history_command_validation_is_before_any_prior_action(store, monkeypatch): + effects = [] + monkeypatch.setitem(executor.event_dict, "AC_manual_command_probe", lambda: effects.append(1)) + with pytest.raises(ac.AutoControlActionException, match="unknown command"): + executor.execute_action([["AC_manual_command_probe"], + ["AC_history_capture", {"body": [["AC_unknown_manual_body"]]}]], raise_on_error=True) + assert effects == [] and store._conn is None + + +def test_positional_capture_is_rejected_without_body_effects(store): + with pytest.raises(TypeError): + executor.execute_action([["AC_history_capture", [[["AC_set_var", {"name": "x", "value": 1}]]]]], + raise_on_error=True) + assert store._conn is None + + +def test_history_command_viewer_denied_before_row(store): + with authorization_scope(AuthorizationContext("viewer", Role.VIEWER)): + with pytest.raises(ac.AuthorizationError): + executor.execute_action([["AC_history_capture", {"body": [["AC_screen_size"]]}]], + raise_on_error=True) + assert store._conn is None + + +def test_outer_command_dry_run_opens_no_history(store): + executor.execute_action([["AC_history_capture", {"body": [["AC_screen_size"]]}]], dry_run=True) + assert store._conn is None + + +def test_command_body_data_is_interpolated_once(store, monkeypatch): + from je_auto_control.utils.script_vars.execution import execution_scope + seen = [] + monkeypatch.setitem(executor.event_dict, "AC_manual_data_probe", lambda value: seen.append(value)) + monkeypatch.setattr(ac.default_secret_manager, "get", lambda name: pytest.fail("data reinterpreted as secret")) + with execution_scope({"message": "${secrets.PRIVATE}"}): + executor.execute_action([["AC_history_capture", {"body": [ + ["AC_manual_data_probe", {"value": "${message}"}]]}]], raise_on_error=True) + assert seen == ["${secrets.PRIVATE}"] and store.list_runs()[0].status == "ok" + + +def test_mcp_generic_execute_rejects_admin_body_before_manual_history(store): + from je_auto_control.utils.mcp_server import MCPServer, build_default_tool_registry + tool = next(tool for tool in build_default_tool_registry(read_only=False, aliases=False) + if tool.name == "ac_execute_actions") + server = MCPServer(tools=[tool]) + request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { + "name": tool.name, "arguments": {"actions": [["AC_history_capture", {"body": [["AC_admin_list_hosts"]]}]]}}} + with authorization_scope(AuthorizationContext("operator", Role.OPERATOR)): + reply = json.loads(server.handle_line(json.dumps(request))) + assert "error" in reply and store._conn is None + + +@pytest.mark.parametrize("signal,rows", [("AC_break", 1), ("AC_continue", 3)]) +def test_normal_loop_signals_do_not_mark_manual_history_failed(store, signal, rows): + actions = [["AC_loop", {"times": 3, "body": [ + ["AC_history_capture", {"body": [[signal]]}]]}]] + executor.execute_action(actions, raise_on_error=True) + history = store.list_runs() + assert len(history) == rows and all(row.status == "ok" for row in history) + + +def test_loop_signal_keeps_preceding_recorded_failure(store, monkeypatch): + def fail(): + raise ValueError("controlled") + monkeypatch.setitem(executor.event_dict, "AC_manual_command_fail", fail) + executor.execute_action([["AC_loop", {"times": 2, "body": [ + ["AC_history_capture", {"body": [["AC_manual_command_fail"], ["AC_break"]]}]]}]]) + assert len(store.list_runs()) == 1 and store.list_runs()[0].status == "error" + + +def test_mcp_generic_execute_records_an_explicit_manual_owner(store): + from je_auto_control.utils.mcp_server import MCPServer, build_default_tool_registry + tool = next(tool for tool in build_default_tool_registry(read_only=False, aliases=False) + if tool.name == "ac_execute_actions") + server = MCPServer(tools=[tool]) + request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { + "name": tool.name, "arguments": {"actions": [["AC_history_capture", { + "body": [["AC_set_var", {"name": "x", "value": 1}]]}]]}}} + with authorization_scope(AuthorizationContext("operator", Role.OPERATOR)): + reply = json.loads(server.handle_line(json.dumps(request))) + assert reply["result"]["isError"] is False + data = json.loads(reply["result"]["content"][0]["text"]) + assert "history_id" in json.dumps(data) + assert store.list_runs()[0].status == "ok" and store.list_runs()[0].source_type == "manual" diff --git a/test/unit_test/headless/test_manual_history_gui.py b/test/unit_test/headless/test_manual_history_gui.py new file mode 100644 index 000000000..83df65f5b --- /dev/null +++ b/test/unit_test/headless/test_manual_history_gui.py @@ -0,0 +1,146 @@ +"""Manual GUI controls preserve opt-out behavior and link explicit captures.""" +import json +from types import SimpleNamespace + +import pytest + +pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) +from PySide6.QtWidgets import QApplication, QLineEdit, QTextEdit + +import je_auto_control as ac +from je_auto_control.gui.manual_run_controls import ManualRunControls +from je_auto_control.gui._script_tab import ScriptTabMixin +from je_auto_control.gui.script_builder.builder_tab import ScriptBuilderTab +from je_auto_control.gui.script_builder.step_model import action_to_step +from je_auto_control.utils.run_history import manual as manual_module + + +@pytest.fixture +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def store(tmp_path, monkeypatch): + value = ac.HistoryStore(tmp_path / "history.sqlite") + monkeypatch.setattr(manual_module, "default_history_store", value) + yield value + value.close() + + +def test_controls_default_to_no_recording_or_database(app, store): + controls = ManualRunControls() + try: + assert not controls.record_history.isChecked() and controls.journal_path.text() == "" + with controls.capture(source_id="test", script_path=""): + pass + assert store._conn is None + finally: + controls.deleteLater() + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_editor_capture_preserves_result_and_links_opted_in_owner(app, store, tmp_path, enabled): + controls = ManualRunControls() + journal = tmp_path / "editor.jsonl" + controls.record_history.setChecked(enabled) + controls.journal_path.setText(str(journal)) + editor, output = QTextEdit(), QTextEdit() + editor.setPlainText(json.dumps([["AC_set_var", {"name": "x", "value": 1}]])) + host = SimpleNamespace(script_editor=editor, script_result_text=output, script_history_controls=controls) + try: + ScriptTabMixin._execute_manual_script(host) + assert json.loads(output.toPlainText()) and len(ac.read_events(journal)) == 2 + rows = store.list_runs() + assert len(rows) == int(enabled) + if enabled: + assert rows[0].status == "ok" and len(store.journals_for_run(rows[0].id)) == 1 + finally: + controls.deleteLater() + editor.deleteLater() + output.deleteLater() + + +def test_file_execution_records_its_source_path(app, store, tmp_path): + controls = ManualRunControls() + controls.record_history.setChecked(True) + script = tmp_path / "script.json" + script.write_text('[["AC_set_var", {"name": "x", "value": 1}]]', encoding="utf-8") + source, output = QLineEdit(str(script)), QTextEdit() + host = SimpleNamespace(script_path_input=source, script_result_text=output, script_history_controls=controls) + try: + ScriptTabMixin._execute_script(host) + assert store.list_runs()[0].script_path == str(script) + assert json.loads(output.toPlainText()) + finally: + for widget in (controls, source, output): + widget.deleteLater() + + +def test_builder_records_with_optional_journal_without_result_wrapper(app, store, tmp_path): + builder = ScriptBuilderTab() + path = tmp_path / "builder.jsonl" + try: + builder._capture.record_history.setChecked(True) + builder._capture.journal_path.setText(str(path)) + builder._tree.add_step(action_to_step(["AC_set_var", {"name": "x", "value": 1}])) + builder._on_run() + result = json.loads(builder._result.toPlainText()) + assert result and "history_id" not in result + row = store.list_runs()[0] + assert row.status == "ok" and len(store.journals_for_run(row.id)) == 1 + finally: + builder.deleteLater() + + +def test_capture_command_form_keeps_body_when_metadata_changes(app): + from je_auto_control.gui.script_builder.step_form_view import StepFormView + from je_auto_control.gui.script_builder.step_model import step_to_action + body = [["AC_get_var", {"name": "x"}]] + step = action_to_step(["AC_history_capture", {"source_id": "before", "body": body}]) + form = StepFormView() + try: + form.load_step(step) + form._editors["source_id"].setText("after") + result = step_to_action(step) + assert result[1]["source_id"] == "after" and result[1]["body"] == body + finally: + form.deleteLater() + + +def test_directory_execution_records_one_batch_and_all_snapshots(app, store, tmp_path): + directory = tmp_path / "scripts" + directory.mkdir() + for index in range(2): + (directory / f"{index}.json").write_text( + json.dumps([["AC_set_var", {"name": f"x{index}", "value": index}]]), encoding="utf-8") + controls = ManualRunControls() + controls.record_history.setChecked(True) + journal = tmp_path / "directory.jsonl" + controls.journal_path.setText(str(journal)) + source, output = QLineEdit(str(directory)), QTextEdit() + host = SimpleNamespace(script_dir_input=source, script_result_text=output, script_history_controls=controls) + try: + ScriptTabMixin._execute_dir(host) + assert len(json.loads(output.toPlainText())) == 2 + assert len(store.list_runs()) == 1 and len(ac.read_events(journal)) == 4 + assert store.list_runs()[0].script_path == str(directory) + finally: + for widget in (controls, source, output): + widget.deleteLater() + + +def test_manual_controls_retranslate_without_resetting_options(app): + from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper + controls = ManualRunControls() + old = language_wrapper.language + try: + controls.record_history.setChecked(True) + controls.journal_path.setText("events.jsonl") + language_wrapper.reset_language("Japanese") + controls.retranslate() + assert controls.record_history.text() == "実行履歴に記録" + assert controls.record_history.isChecked() and controls.journal_path.text() == "events.jsonl" + finally: + language_wrapper.reset_language(old) + controls.deleteLater() From e2827e8dd8376892350738de10700221fd1d41e5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 04:02:53 +0800 Subject: [PATCH 024/294] Preserve self-healing cancellation and mask audit secrets --- CHANGELOG.md | 5 + README.md | 2 + README/README_zh-CN.md | 2 + README/README_zh-TW.md | 2 + architecture_explore.md | 10 +- docs/API_LIFECYCLE.md | 4 + docs/CAPABILITY_MATRIX.md | 2 + .../Eng/doc/new_features/v2_features_doc.rst | 4 + .../Zh/doc/new_features/v2_features_doc.rst | 4 + docs/updates/2026-10.md | 8 ++ docs/updates/README.md | 3 +- .../utils/self_healing/heal_log.py | 11 +- je_auto_control/utils/self_healing/locator.py | 13 +- .../headless/test_self_healing_boundaries.py | 134 ++++++++++++++++++ 14 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 test/unit_test/headless/test_self_healing_boundaries.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8949439ba..6fab70f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Self-healing preserves cancellation instead of treating it as an image/VLM + miss and continuing to fallback or click. Audit writes and reads mask secret + values already resolved in the current run, preserving original outcomes and + leaving legacy log files untouched on read. + - Explicit manual history is available through `manual_history_scope`, `AC_history_capture`, CLI `run --history [--history-db PATH]`, and opt-in controls in Script Executor / Script Builder. GUI and CLI results keep their diff --git a/README.md b/README.md index 882a05cba..aad802848 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ desktop app; tab commands live in the window's **Actions** menu. | Diagnostics | `run_diagnostics` | `AC_diagnose` | Diagnostics | | Test-code generation | `generate_code` | — | — | +Self-healing propagates cancellation before fallback or clicking. Its audit log masks secrets already resolved in the current run before writing or returning records; returned locator outcomes retain their original values. Legacy log files are not rewritten. + Beyond this table, `utils/` holds 311 headless packages covering assertions, resilience, data quality, i18n auditing, redaction, governance, observability, and more. The full per-module map is in **[architecture_explore.md](architecture_explore.md)**. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index bbc0bf71b..4161587c4 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -241,6 +241,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 系统诊断 | `run_diagnostics` | `AC_diagnose` | Diagnostics | | 测试代码生成 | `generate_code` | — | — | +自愈定位器会在回退或点击前原样传递取消。稽核日志在写入或返回记录前遮蔽本次运行已解析的秘密值;返回的定位结果保留原值,读取时不改写既有日志文件。 + 除了这张表,`utils/` 下还有 311 个无头包,覆盖断言、韧性、数据质量、i18n 审计、脱敏、 治理、可观测性等等。完整的逐模块地图在 **[architecture_explore.md](../architecture_explore.md)**。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index d1d418185..4cd61a6e6 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -240,6 +240,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 系統診斷 | `run_diagnostics` | `AC_diagnose` | Diagnostics | | 測試碼產生 | `generate_code` | — | — | +自癒定位器會在後備或點擊前原樣傳遞取消。稽核記錄在寫入或回傳記錄前遮蔽本次執行已解析的秘密值;回傳的定位結果保留原值,讀取時不改寫既有紀錄檔。 + 除了這張表,`utils/` 底下還有 311 個無頭套件,涵蓋斷言、韌性、資料品質、i18n 稽核、遮蔽、 治理、可觀測性等等。完整的逐模組地圖在 **[architecture_explore.md](../architecture_explore.md)**。 diff --git a/architecture_explore.md b/architecture_explore.md index 4cbb6c8cf..8ce447ef6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,096 | -| 程式碼總行數 | 164,138 | +| 程式碼總行數 | 164,150 | | `je_auto_control/utils/` 子套件數 | 311 | | `AC_*` 動作指令數(`known_commands()` 實測) | 782 | | 套件門面 `__all__` 公開名稱數 | 1,279 | @@ -465,7 +465,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.8 元素定位、自我修復與智慧等待 -> 23 個套件、約 4,426 行。 +> 23 個套件、約 4,438 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -488,7 +488,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/observation_delta/` | 122 | token 預算內的觀察差異:兩個 UI 影格之間變了什麼 | | `utils/screen_state/` | 191 | 語義畫面狀態:快照/差異與結構化畫面描述 | | `utils/scroll_find/` | 103 | 捲動直到目標影像/文字可見 | -| `utils/self_healing/` | 359 | 自癒定位器:先影像樣板、失敗改用 VLM,並留稽核記錄 | +| `utils/self_healing/` | 371 | 自癒定位器:先影像樣板、失敗改用 VLM,並留稽核記錄 | | `utils/semantic_recording/` | 498 | 為錄製內容加上語義錨點,支援換機重播與自癒重播 | | `utils/settle_detector/` | 79 | 以純函式介面判定 UI 是否已靜止 | | `utils/smart_waits/` | 672 | 智慧等待:以影格差異取代 `time.sleep` | @@ -1106,6 +1106,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 702 | 59,986 | -| **總計** | **1,090** | **164,073** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 702 | 59,998 | +| **總計** | **1,090** | **164,085** | diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index 7196f346c..c59302b3d 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -31,6 +31,10 @@ experimental snapshots. `manual_history_scope` and its `HistoryBinding` value, experimental. The scope records an explicit owner without replacing variables or result values; the command returns a separate history/result summary. +Self-healing retains its existing signatures and outcome format. Cancellation +propagates before recovery; audit storage and reads mask secret values known to +the current run without mutating the returned outcome or rewriting legacy files. + - Stable API removal requires a deprecation warning and two minor releases. - Beta API removal requires one release note and one minor release. - Experimental API may change in any release and must be labelled as such. diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index a2d66c4a0..cf4873e49 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -28,6 +28,8 @@ Journal storage, executor capture, provenance links, facade/CLI entry points and Script Builder commands, observed runs and MCP journal tools have controlled tests. The journal work does not claim native device acceptance. Run History journal browsing also has controlled offscreen tests. Explicit manual history ownership has controlled API, command, CLI, GUI and MCP execution tests. +Self-healing cancellation and secret masking have controlled headless tests, +including registered command and MCP adapters; this adds no native or paid API acceptance. Linux Wayland is split: **capture is exercised by CI against a real compositor; input is exercised by CI against a real EI peer and a real portal.** diff --git a/docs/source/Eng/doc/new_features/v2_features_doc.rst b/docs/source/Eng/doc/new_features/v2_features_doc.rst index edeec5565..8b0051046 100644 --- a/docs/source/Eng/doc/new_features/v2_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v2_features_doc.rst @@ -32,6 +32,10 @@ locators can be tuned over time:: Executor: ``AC_self_heal_locate / _click / _log_list / _log_clear``. MCP: ``ac_self_heal_*``. GUI: **Self-Healing** tab. +Cancellation propagates unchanged before fallback, clicking or miss logging. +Audit writes and reads mask secret values already resolved in the active run. +The original locator outcome remains unchanged; reads do not rewrite legacy logs. + Anchor-based locator -------------------- diff --git a/docs/source/Zh/doc/new_features/v2_features_doc.rst b/docs/source/Zh/doc/new_features/v2_features_doc.rst index 21c229b6f..e31f44854 100644 --- a/docs/source/Zh/doc/new_features/v2_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v2_features_doc.rst @@ -30,6 +30,10 @@ Executor:``AC_self_heal_locate / _click / _log_list / _log_clear``。 MCP:``ac_self_heal_*``。GUI:**Self-Healing** 分頁。 +取消會原樣傳遞,中止後備定位、點擊與失敗紀錄。 +稽核記錄在寫入與讀取時遮蔽本次執行已解析的秘密值。 +定位結果保留原值;讀取時不會改寫既有紀錄檔。 + 錨點定位器 ---------- diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 7909c05f8..070fa2976 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -508,3 +508,11 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Examples and visual check**: example 30 validates without creating its output directory; actual variable-only execution and preview each produce an ok history row, two corresponding journal snapshots and a provenance link. The 1,000×650 Script Builder render shows the opt-in checkbox and journal path without replacing the editor or result display. Probe fonts are loaded only in the offscreen QA process; no desktop input, native-device acceptance or paid API is invoked. - **Files and docs**: manual scope/command adapters, public binding imports/exports, CLI, shared GUI controls and two run surfaces, schema/RBAC, outcome formatting, regenerated actions.pyi, four languages, regressions and unique legacy probes, example 30, three READMEs, bilingual CLI/journal references, lifecycle/capability matrix, measured architecture maps and count generators (1,279 facade names, 782 commands, 682 MCP names, 30 examples). - **Open items**: B2/B3, the broader C–H modernization and shared GUI worker/lifecycle work remain in Progress.md. Native-device and paid-API acceptance remain deferred at the user's request. + +## U-20261019-07 · 2026-10-19 · Preserve self-healing cancellation and mask audit secrets · #bugfix #self-healing + +- **Validation**: the full suite passes 11,970 tests with 69 skipped and 87.84% coverage. The final 119-case focused selection includes the successful-fallback compatibility case added after full-suite collection; production sources stayed unchanged throughout coverage. Both changed modules pass strict mypy, and whole-package typing has zero errors on win32/linux/darwin. Whole-package Ruff, Bandit and radon pass; strict English/Chinese Sphinx builds pass. Whole-package Pylint ran under its default settings (7.87/10) and retains existing Qt/import/style/signature diagnostics; no diagnostic points at newly added fix logic. The previous stage's CI run 39160451456 completed successfully. +- **What**: image and VLM exception containment now consults the shared run-interruption classifier before formatting a recoverable failure. MCP cancellation propagates as the exact original exception rather than continuing through VLM fallback, miss logging or a mouse click. Other interruption classes retain their existing propagation. No signature, result shape or device effect is added. +- **Privacy**: HealEventLog snapshots known run-local secrets before JSON serialization and masks legacy records on read under the current run mask. The input event and returned locator outcome retain original values. Reads leave the original file bytes untouched; values not known to the active run cannot be retroactively masked. +- **Regression evidence**: the initial 27-case selection reproduced 11 failures: four swallowed MCP-cancellation paths, six persisted secret-bearing fields and one legacy-record read. Final focused self-healing, documentation, stub, size, complexity and exception-reason gates pass 119 cases. The additional cases exercise registered executor/MCP adapters, unformattable cancellation and successful ordinary fallback with unchanged outcomes. All locators and clicks are controlled fakes; no desktop capture/input or paid API is invoked by these probes. +- **Files and documentation**: self-healing locator/log modules and boundary tests; three aligned READMEs, bilingual Sphinx usage, API lifecycle, capability matrix, changelog and regenerated architecture line counts. Broader B2 comparison/capture/telemetry/candidate work remains in Progress.md; native-device and paid-API acceptance remain deferred at the user's request. diff --git a/docs/updates/README.md b/docs/updates/README.md index 139790f55..166a2b9f1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261019-07 | 2026-10-19 | Preserve self-healing cancellation and mask audit secrets | #bugfix #self-healing | [2026-10](2026-10.md) | | U-20261019-06 | 2026-10-19 | Record explicit manual runs across API, CLI and GUI | #done #history #journal | [2026-10](2026-10.md) | | U-20261019-05 | 2026-10-19 | Browse journal snapshots and history links in Run History | #done #journal #gui | [2026-10](2026-10.md) | | U-20261019-04 | 2026-10-19 | Add observed journal runs and dedicated MCP operations | #done #journal #mcp | [2026-10](2026-10.md) | @@ -388,7 +389,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 52 | +| [2026-10.md](2026-10.md) | 2026-10 | 53 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/utils/self_healing/heal_log.py b/je_auto_control/utils/self_healing/heal_log.py index 98e3fcde7..5504aa490 100644 --- a/je_auto_control/utils/self_healing/heal_log.py +++ b/je_auto_control/utils/self_healing/heal_log.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from je_auto_control.utils.json_store.json_store import append_json_line +from je_auto_control.utils.logging.secret_scope import scrub_secret_data @dataclass(frozen=True) @@ -54,13 +55,13 @@ def path(self) -> Path: return self._path def append(self, event: HealEvent) -> None: - """Atomically append one event as a JSON line.""" - payload = json.dumps(event.to_dict(), ensure_ascii=False) + """Append a snapshot with known run-local secrets masked before I/O.""" + payload = json.dumps(scrub_secret_data(event.to_dict()), ensure_ascii=False) with self._lock: append_json_line(self._path, payload) def list_events(self, limit: int = 100) -> List[HealEvent]: - """Return up to ``limit`` most-recent events (oldest first in slice).""" + """Return recent events, masking known secrets without rewriting disk.""" capped = max(0, int(limit)) if capped == 0: return [] @@ -97,9 +98,11 @@ def _parse_line(raw: str) -> Optional[HealEvent]: if not text: return None try: - payload = json.loads(text) + payload = scrub_secret_data(json.loads(text)) except ValueError: return None + if not isinstance(payload, dict): + return None try: return HealEvent(**payload) except TypeError: diff --git a/je_auto_control/utils/self_healing/locator.py b/je_auto_control/utils/self_healing/locator.py index 98253553b..308408d78 100644 --- a/je_auto_control/utils/self_healing/locator.py +++ b/je_auto_control/utils/self_healing/locator.py @@ -76,6 +76,7 @@ def self_heal_locate(template_path: Optional[str] = None, At least one of ``template_path`` / ``description`` must be given. Provide both for full self-healing — the VLM path only runs when the template match fails or returns no candidates. + Cancellation propagates unchanged without fallback, click or miss logging. """ if not template_path and not description: raise ValueError( @@ -152,7 +153,7 @@ def _try_image(template_path: Optional[str], except ImageNotFoundException as exc: return None, str(exc) except (AutoControlException, OSError, RuntimeError, ValueError, TypeError) as exc: - return None, repr(exc) + return None, _failure_detail(exc) def _try_vlm(description: Optional[str], @@ -169,12 +170,20 @@ def _try_vlm(description: Optional[str], # AutoControlException: a failed screenshot (AutoControlScreenException) # escaped the self-heal and left no heal-log entry. except (AutoControlException, OSError, RuntimeError, ValueError, TypeError) as exc: - return None, repr(exc) + return None, _failure_detail(exc) if coords is None: return None, "vlm returned no match" return (int(coords[0]), int(coords[1])), None +def _failure_detail(error: Exception) -> str: + """Propagate cancellation before formatting an ordinary locator failure.""" + # pylint: disable-next=import-outside-toplevel # reason: run outcomes import the executor during facade setup + from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted + raise_if_run_interrupted(error) + return repr(error) + + def _click_at(coordinates: Tuple[int, int], mouse_keycode: str) -> None: from je_auto_control.wrapper.auto_control_mouse import ( click_mouse, set_mouse_position, diff --git a/test/unit_test/headless/test_self_healing_boundaries.py b/test/unit_test/headless/test_self_healing_boundaries.py new file mode 100644 index 000000000..1738e6453 --- /dev/null +++ b/test/unit_test/headless/test_self_healing_boundaries.py @@ -0,0 +1,134 @@ +"""Self-healing must preserve interruption and mask persisted diagnostics.""" +import asyncio +from concurrent.futures import CancelledError as FutureCancelledError +from dataclasses import replace + +import pytest + +from je_auto_control.utils.exception.exceptions import ImageNotFoundException +from je_auto_control.utils.logging.secret_scope import remember_secret, secret_log_scope +from je_auto_control.utils.mcp_server.context import OperationCancelledError +from je_auto_control.utils.self_healing import HealEvent, HealEventLog, self_heal_click, self_heal_locate + + +@pytest.mark.parametrize("entry", [self_heal_locate, self_heal_click]) +@pytest.mark.parametrize("strategy", ["image", "vlm"]) +@pytest.mark.parametrize("signal_type", [ + OperationCancelledError, FutureCancelledError, asyncio.CancelledError, KeyboardInterrupt, SystemExit, +]) +def test_interruption_never_falls_back_clicks_or_logs(monkeypatch, tmp_path, entry, strategy, signal_type): + signal = signal_type("cancelled-probe") + calls = [] + log = HealEventLog(tmp_path / "events.jsonl") + + def image(*_args, **_kwargs): + calls.append("image") + if strategy == "image": + raise signal + raise ImageNotFoundException("missing") + + def vlm(*_args, **_kwargs): + calls.append("vlm") + if strategy == "vlm": + raise signal + return (4, 5) + + monkeypatch.setattr("je_auto_control.wrapper.auto_control_image.locate_image_center", image) + monkeypatch.setattr("je_auto_control.utils.vision.vlm_api.locate_by_description", vlm) + monkeypatch.setattr("je_auto_control.utils.self_healing.locator._click_at", lambda *_: calls.append("click")) + with pytest.raises(signal_type) as caught: + entry(template_path="probe.png", description="probe", log=log) + assert caught.value is signal + assert calls == (["image"] if strategy == "image" else ["image", "vlm"]) + assert not log.path.exists() + + +@pytest.mark.parametrize("field", ["timestamp", "method", "template_path", "description", "image_error", "vlm_error"]) +def test_log_masks_known_values_before_write_without_mutating_event(tmp_path, field): + secret = '密碼-"probe"\nvalue' + event = replace(HealEvent("now", "image", [4, 5], 1.0), **{field: f"before:{secret}:after"}) + log = HealEventLog(tmp_path / "events.jsonl") + with secret_log_scope(): + remember_secret(secret) + log.append(event) + assert getattr(log.list_events()[0], field) == "before:***:after" + assert getattr(event, field) == f"before:{secret}:after" + assert "probe" not in log.path.read_text(encoding="utf-8") + + +def test_read_masks_legacy_record_in_current_scope_without_rewriting_file(tmp_path): + log = HealEventLog(tmp_path / "events.jsonl") + event = HealEvent("now", "vlm", [4, 5], 1.0, description="legacy-secret") + log.append(event) + original = log.path.read_bytes() + with secret_log_scope(): + remember_secret("legacy-secret") + assert log.list_events()[0].description == "***" + assert log.path.read_bytes() == original + assert log.list_events()[0] == event + + +@pytest.mark.parametrize("surface", ["command", "mcp"]) +@pytest.mark.parametrize("operation", ["locate", "click"]) +def test_registered_surfaces_preserve_cancellation(monkeypatch, tmp_path, surface, operation): + from je_auto_control.utils.executor.action_executor import executor + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + + signal = OperationCancelledError("boundary-probe") + log = HealEventLog(tmp_path / "events.jsonl") + calls = [] + + def cancel(*_args, **_kwargs): + raise signal + + monkeypatch.setattr("je_auto_control.wrapper.auto_control_image.locate_image_center", cancel) + monkeypatch.setattr("je_auto_control.utils.self_healing.locator.default_heal_log", log) + monkeypatch.setattr("je_auto_control.utils.self_healing.locator._try_vlm", lambda *_: calls.append("vlm")) + monkeypatch.setattr("je_auto_control.utils.self_healing.locator._click_at", lambda *_: calls.append("click")) + arguments = {"template_path": "probe.png", "description": "probe"} + with pytest.raises(OperationCancelledError) as caught: + if surface == "command": + executor.execute_action([[f"AC_self_heal_{operation}", arguments]]) + else: + tool = next(tool for tool in build_default_tool_registry() if tool.name == f"ac_self_heal_{operation}") + tool.handler(**arguments) + assert caught.value is signal + assert calls == [] + assert not log.path.exists() + + +def test_cancellation_is_not_formatted(monkeypatch, tmp_path): + class UnformattableCancellation(OperationCancelledError): + def __repr__(self): + pytest.fail("cancellation must propagate before repr") + + signal = UnformattableCancellation("probe") + + def cancel(*_args, **_kwargs): + raise signal + + monkeypatch.setattr("je_auto_control.wrapper.auto_control_image.locate_image_center", cancel) + with pytest.raises(OperationCancelledError) as caught: + self_heal_locate(template_path="probe.png", log=HealEventLog(tmp_path / "events.jsonl")) + assert caught.value is signal + + +def test_successful_fallback_keeps_original_outcome_and_masks_only_audit(monkeypatch, tmp_path): + secret = "private-locator-value" + log = HealEventLog(tmp_path / "events.jsonl") + + def image(*_args, **_kwargs): + raise RuntimeError(f"ordinary miss: {secret}") + + monkeypatch.setattr("je_auto_control.wrapper.auto_control_image.locate_image_center", image) + monkeypatch.setattr("je_auto_control.utils.vision.vlm_api.locate_by_description", lambda *_a, **_k: (4, 5)) + with secret_log_scope(): + remember_secret(secret) + outcome = self_heal_locate(template_path=secret, description=secret, log=log) + assert outcome.found and outcome.coordinates == (4, 5) + assert outcome.template_path == outcome.description == secret + assert secret in outcome.image_error + stored = log.list_events()[0] + assert stored.template_path == stored.description == "***" + assert "ordinary miss" in stored.image_error + assert secret not in log.path.read_text(encoding="utf-8") From 54491442a6968db815bb3b22d799e9922063ba4f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 19 Oct 2026 04:35:20 +0800 Subject: [PATCH 025/294] Compare locator versions on fixed frames across execution surfaces --- CHANGELOG.md | 8 + Progress.md | 3 +- README.md | 8 +- README/README_zh-CN.md | 8 +- README/README_zh-TW.md | 8 +- architecture.md | 7 + architecture_explore.md | 74 ++--- benchmarks/self_healing/README.md | 30 +++ benchmarks/self_healing/acceptance.json | 4 + benchmarks/self_healing/build_fixtures.py | 22 ++ benchmarks/self_healing/dataset.json | 18 ++ benchmarks/self_healing/fixtures/absent.png | Bin 0 -> 159 bytes .../self_healing/fixtures/frame_new.png | Bin 0 -> 976 bytes .../self_healing/fixtures/frame_old.png | Bin 0 -> 976 bytes benchmarks/self_healing/fixtures/new.png | Bin 0 -> 744 bytes benchmarks/self_healing/fixtures/old.png | Bin 0 -> 744 bytes docs/API_LIFECYCLE.md | 5 + docs/CAPABILITY_MATRIX.md | 3 + .../new_features/healing_evaluation_doc.rst | 45 ++++ docs/source/Eng/eng_index.rst | 1 + .../new_features/healing_evaluation_doc.rst | 37 +++ docs/source/Zh/zh_index.rst | 1 + docs/updates/2026-10.md | 10 + docs/updates/README.md | 3 +- je_auto_control/__init__.py | 6 + je_auto_control/actions.pyi | 3 + .../gui/healing_evaluation_panel.py | 215 +++++++++++++++ .../gui/healing_evaluation_worker.py | 68 +++++ .../gui/language_wrapper/english.py | 29 ++ .../gui/language_wrapper/japanese.py | 28 ++ .../language_wrapper/simplified_chinese.py | 28 ++ .../language_wrapper/traditional_chinese.py | 28 ++ .../gui/script_builder/command_schema.py | 5 + je_auto_control/gui/self_healing_tab.py | 18 +- .../utils/executor/action_executor.py | 2 + .../utils/mcp_server/tools/_factories.py | 3 +- .../mcp_server/tools/_healing_evaluation.py | 40 +++ je_auto_control/utils/rbac/policy.py | 2 + .../utils/self_healing/__init__.py | 12 + .../utils/self_healing/evaluation.py | 175 ++++++++++++ .../utils/self_healing/evaluation_io.py | 162 +++++++++++ .../utils/self_healing/evaluation_models.py | 235 ++++++++++++++++ .../utils/self_healing/frame_locators.py | 141 ++++++++++ .../headless/test_self_healing_evaluation.py | 254 ++++++++++++++++++ .../test_self_healing_evaluation_gui.py | 121 +++++++++ .../test_self_healing_evaluation_surfaces.py | 71 +++++ 46 files changed, 1893 insertions(+), 48 deletions(-) create mode 100644 benchmarks/self_healing/README.md create mode 100644 benchmarks/self_healing/acceptance.json create mode 100644 benchmarks/self_healing/build_fixtures.py create mode 100644 benchmarks/self_healing/dataset.json create mode 100644 benchmarks/self_healing/fixtures/absent.png create mode 100644 benchmarks/self_healing/fixtures/frame_new.png create mode 100644 benchmarks/self_healing/fixtures/frame_old.png create mode 100644 benchmarks/self_healing/fixtures/new.png create mode 100644 benchmarks/self_healing/fixtures/old.png create mode 100644 docs/source/Eng/doc/new_features/healing_evaluation_doc.rst create mode 100644 docs/source/Zh/doc/new_features/healing_evaluation_doc.rst create mode 100644 je_auto_control/gui/healing_evaluation_panel.py create mode 100644 je_auto_control/gui/healing_evaluation_worker.py create mode 100644 je_auto_control/utils/mcp_server/tools/_healing_evaluation.py create mode 100644 je_auto_control/utils/self_healing/evaluation.py create mode 100644 je_auto_control/utils/self_healing/evaluation_io.py create mode 100644 je_auto_control/utils/self_healing/evaluation_models.py create mode 100644 je_auto_control/utils/self_healing/frame_locators.py create mode 100644 test/unit_test/headless/test_self_healing_evaluation.py create mode 100644 test/unit_test/headless/test_self_healing_evaluation_gui.py create mode 100644 test/unit_test/headless/test_self_healing_evaluation_surfaces.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fab70f70..f1929f5d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +- Experimental fixed-frame locator comparisons share schema-v1 reports across + API, AC commands, MCP, CLI action files and the Self-Healing GUI. Ratios show + eligible populations and unknowns; recovery requires both correct localization + and explicit operation verification. Reports include latency, optional USD + costs and provenance, with JSON/HTML export. Local manifests are bounded and + never select a network backend. GUI comparisons run off-thread and support + cancellation and safe disposal of stale results. + - Self-healing preserves cancellation instead of treating it as an image/VLM miss and continuing to fallback or click. Audit writes and reads mask secret values already resolved in the current run, preserving original outcomes and diff --git a/Progress.md b/Progress.md index a0be446d2..b53301325 100644 --- a/Progress.md +++ b/Progress.md @@ -14,7 +14,8 @@ `WIP` — 計畫 A(既有決策與執行契約)已交付,A2、A7 在 2026-10-08、其餘在 2026-10-09(U-20261009-01…14); 各項沒驗到或沒做完的部分在本檔下面三節(輸入與視窗、macOS、這一輪修正留下的後續)。計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02)。 -計畫 B(動作日誌、自愈量測、codegen)仍缺 B2 自愈評估與 B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: +計畫 B 仍缺 B2 的執行時共用擷取/區域修正、HealEvent 版本遙測、候選樣板預覽/驗證/回復, +以及 B3 日誌產碼。計畫 C(持久化同步與遠端 session;C4 以外)、D(Wayland)、E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺: - **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 - **F1 的遺留面板釋放**:擁有者直接建立的核心表單與 Remote Desktop 在關閉時仍保留 widget;需隨 F3 遷移直接控制項引用、執行中工作與連線生命週期,再啟用銷毀。 diff --git a/README.md b/README.md index aad802848..c45e8cae0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ from JSON files / CLI / servers, and a **GUI tab**. Nothing is GUI-only. - **One API, seven platforms.** `wrapper/platform_wrapper.py` picks the backend at import time; your script does not change between Windows, macOS, X11, and Wayland. -- **Scriptable without Python.** 782 `AC_*` commands cover the whole feature set, so a +- **Scriptable without Python.** 783 `AC_*` commands cover the whole feature set, so a JSON file can do anything the library can — including loops, branches, try/catch, macros, and variables. - **Headless by default.** `import je_auto_control` never loads Qt. The GUI is an @@ -233,7 +233,7 @@ desktop app; tab commands live in the window's **Actions** menu. | Natural-language planner | `plan_actions`, `run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`, `run_agent` | `AC_run_agent` | Computer Use | | Record & replay | `record`, `stop_record` | `AC_record`, `AC_stop_record` | Record | -| JSON scripting | `execute_action`, `execute_files` | all 782 commands | Script, Script Builder | +| JSON scripting | `execute_action`, `execute_files` | all 783 commands | Script, Script Builder | | Variables & flow control | `execute_action_with_vars` | `AC_set_var`, `AC_loop`, `AC_for_each`, `AC_try`, `AC_retry` | Variables | | Data-driven runs | — | `AC_for_each_row` (CSV / JSON / SQLite / Excel) | Data Sources | | Assertions | `assert_text`, `assert_image` | `AC_assert_text` + 20 more | Assertions | @@ -255,6 +255,8 @@ desktop app; tab commands live in the window's **Actions** menu. Self-healing propagates cancellation before fallback or clicking. Its audit log masks secrets already resolved in the current run before writing or returning records; returned locator outcomes retain their original values. Legacy log files are not rewritten. +Experimental fixed-frame comparisons use `evaluate_locators` or `evaluate_healing_manifest`, `AC_self_heal_evaluate`, MCP `ac_self_heal_evaluate`, and **Self-Healing → Version comparison**. Reports separate labelled accuracy, wrong predictions, unknowns and verified recovery, include p50/p95 and provenance, and export JSON/HTML through `write_healing_report` or GUI Actions. The local manifest operation never captures, clicks or selects a network backend. See the [dataset, populations and limits](benchmarks/self_healing/README.md). + Beyond this table, `utils/` holds 311 headless packages covering assertions, resilience, data quality, i18n auditing, redaction, governance, observability, and more. The full per-module map is in **[architecture_explore.md](architecture_explore.md)**. @@ -302,7 +304,7 @@ under a relative name are written below that directory. | Surface | Start it with | Notes | |---|---|---| -| **MCP server** | `je_auto_control_mcp` (stdio) or `AC_start_mcp_http_server` | 682 tools for Claude Desktop / Claude Code / custom tool loops: the full `ac_*` surface plus short model-friendly aliases for common GUI actions. Speaks the stateless MCP 2026-07-28 beside the `initialize`-based revisions. Bearer auth, TLS, audit log, rate limit, plugin hot-reload, CI fake backend. | +| **MCP server** | `je_auto_control_mcp` (stdio) or `AC_start_mcp_http_server` | 683 tools for Claude Desktop / Claude Code / custom tool loops: the full `ac_*` surface plus short model-friendly aliases for common GUI actions. Speaks the stateless MCP 2026-07-28 beside the `initialize`-based revisions. Bearer auth, TLS, audit log, rate limit, plugin hot-reload, CI fake backend. | | **REST API** | `je_auto_control start-rest` | Bearer token, per-IP rate limit + lockout, SQLite audit hook, `/metrics`, `/openapi.json`, `/docs` Swagger UI, `/dashboard`. | | **TCP socket server** | `je_auto_control start-server` | Newline-framed JSON action lists. Binds `127.0.0.1` by default. | | **pytest plugin** | installed automatically | Fixtures plus a Gherkin step library for pytest-bdd / behave. | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 4161587c4..09db0ec48 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -19,7 +19,7 @@ - **一套 API,七个平台。** `wrapper/platform_wrapper.py` 在导入时挑选后端;同一份脚本在 Windows、macOS、X11 与 Wayland 上都不需要改写。 -- **不写 Python 也能脚本化。** 782 个 `AC_*` 命令覆盖全部功能,因此一个 JSON 文件能做到库 +- **不写 Python 也能脚本化。** 783 个 `AC_*` 命令覆盖全部功能,因此一个 JSON 文件能做到库 能做的任何事——包含循环、分支、try/catch、宏与变量。 - **默认无头运行。** `import je_auto_control` 绝不会加载 Qt。GUI 是可选包,包在同一个无头内核之外。 - **四种定位方式。** 模板匹配、OCR、无障碍树、视觉语言模型——可通过锚点定位器与自愈回退串接组合。 @@ -221,7 +221,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 自然语言规划 | `plan_actions`、`run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`、`run_agent` | `AC_run_agent` | Computer Use | | 录制与回放 | `record`、`stop_record` | `AC_record`、`AC_stop_record` | Record | -| JSON 脚本 | `execute_action`、`execute_files` | 全部 782 个命令 | Script、Script Builder | +| JSON 脚本 | `execute_action`、`execute_files` | 全部 783 个命令 | Script、Script Builder | | 变量与流程控制 | `execute_action_with_vars` | `AC_set_var`、`AC_loop`、`AC_for_each`、`AC_try`、`AC_retry` | Variables | | 数据驱动执行 | — | `AC_for_each_row`(CSV/JSON/SQLite/Excel) | Data Sources | | 断言 | `assert_text`、`assert_image` | `AC_assert_text` 等 21 个 | Assertions | @@ -243,6 +243,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 自愈定位器会在回退或点击前原样传递取消。稽核日志在写入或返回记录前遮蔽本次运行已解析的秘密值;返回的定位结果保留原值,读取时不改写既有日志文件。 +实验性固定图像比较支持 `evaluate_locators`、`evaluate_healing_manifest`、`AC_self_heal_evaluate`、MCP `ac_self_heal_evaluate` 与 **Self-Healing → 版本比较**。报告分别呈现已标注正确率、误判、未知与已验证恢复,包含 p50/p95 及来源,可由 `write_healing_report` 或 GUI 动作导出 JSON/HTML。本机数据集入口不会截图、点击或自行选择网络 backend。参阅[数据集、统计母体与限制](../benchmarks/self_healing/README.md)。 + 除了这张表,`utils/` 下还有 311 个无头包,覆盖断言、韧性、数据质量、i18n 审计、脱敏、 治理、可观测性等等。完整的逐模块地图在 **[architecture_explore.md](../architecture_explore.md)**。 @@ -283,7 +285,7 @@ je_auto_control version | 接口 | 启动方式 | 说明 | |---|---|---| -| **MCP 服务器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 682 个工具,供 Claude Desktop/Claude Code/自定义 tool loop 使用:完整的 `ac_*` 工具接口,以及常用 GUI 操作的短名称 alias。除了以 `initialize` 握手的各版协议,也支持无状态的 MCP 2026-07-28。Bearer 认证、TLS、审计日志、限流、插件热重载、CI 假后端。 | +| **MCP 服务器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 683 个工具,供 Claude Desktop/Claude Code/自定义 tool loop 使用:完整的 `ac_*` 工具接口,以及常用 GUI 操作的短名称 alias。除了以 `initialize` 握手的各版协议,也支持无状态的 MCP 2026-07-28。Bearer 认证、TLS、审计日志、限流、插件热重载、CI 假后端。 | | **REST API** | `je_auto_control start-rest` | Bearer token、按 IP 限流与锁定、SQLite 审计 hook、`/metrics`、`/openapi.json`、`/docs` Swagger UI、`/dashboard`。 | | **TCP socket 服务器** | `je_auto_control start-server` | 以换行分隔的 JSON 动作列表。默认绑定 `127.0.0.1`。 | | **pytest 插件** | 安装后自动生效 | 提供 fixture 与供 pytest-bdd/behave 使用的 Gherkin step library。 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 4cd61a6e6..2806153e1 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -18,7 +18,7 @@ - **一套 API,七個平台。** `wrapper/platform_wrapper.py` 在匯入時挑選後端;同一份腳本在 Windows、macOS、X11 與 Wayland 上都不需要改寫。 -- **不寫 Python 也能腳本化。** 782 個 `AC_*` 指令涵蓋全部功能,因此一個 JSON 檔能做到函式庫 +- **不寫 Python 也能腳本化。** 783 個 `AC_*` 指令涵蓋全部功能,因此一個 JSON 檔能做到函式庫 能做的任何事——包含迴圈、分支、try/catch、巨集與變數。 - **預設無頭執行。** `import je_auto_control` 絕不會載入 Qt。GUI 是選用套件,包在同一個無頭核心之外。 - **四種定位方式。** 樣板比對、OCR、無障礙樹、視覺語言模型——可透過錨點定位器與自癒後備串接組合。 @@ -220,7 +220,7 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" | 自然語言規劃 | `plan_actions`、`run_from_description` | `AC_llm_plan` | LLM Planner | | Computer-use agent | `AgentLoop`、`run_agent` | `AC_run_agent` | Computer Use | | 錄製與重播 | `record`、`stop_record` | `AC_record`、`AC_stop_record` | Record | -| JSON 腳本 | `execute_action`、`execute_files` | 全部 782 個指令 | Script、Script Builder | +| JSON 腳本 | `execute_action`、`execute_files` | 全部 783 個指令 | Script、Script Builder | | 變數與流程控制 | `execute_action_with_vars` | `AC_set_var`、`AC_loop`、`AC_for_each`、`AC_try`、`AC_retry` | Variables | | 資料驅動執行 | — | `AC_for_each_row`(CSV/JSON/SQLite/Excel) | Data Sources | | 斷言 | `assert_text`、`assert_image` | `AC_assert_text` 等 21 個 | Assertions | @@ -242,6 +242,8 @@ python -c "import je_auto_control; je_auto_control.start_autocontrol_gui()" 自癒定位器會在後備或點擊前原樣傳遞取消。稽核記錄在寫入或回傳記錄前遮蔽本次執行已解析的秘密值;回傳的定位結果保留原值,讀取時不改寫既有紀錄檔。 +實驗性固定影像比較支援 `evaluate_locators`、`evaluate_healing_manifest`、`AC_self_heal_evaluate`、MCP `ac_self_heal_evaluate` 與 **Self-Healing → 版本比較**。報告分別呈現已標註正確率、誤判、未知與已驗證恢復,包含 p50/p95 及來源,可由 `write_healing_report` 或 GUI 動作匯出 JSON/HTML。本機資料集入口不會截圖、點擊或自行選擇網路 backend。參閱[資料集、統計母體與限制](../benchmarks/self_healing/README.md)。 + 除了這張表,`utils/` 底下還有 311 個無頭套件,涵蓋斷言、韌性、資料品質、i18n 稽核、遮蔽、 治理、可觀測性等等。完整的逐模組地圖在 **[architecture_explore.md](../architecture_explore.md)**。 @@ -282,7 +284,7 @@ je_auto_control version | 介面 | 啟動方式 | 說明 | |---|---|---| -| **MCP 伺服器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 682 個工具,供 Claude Desktop/Claude Code/自訂 tool loop 使用:完整的 `ac_*` 工具介面,以及常用 GUI 操作的短名稱 alias。除了以 `initialize` 握手的各版協定,也支援無狀態的 MCP 2026-07-28。Bearer 驗證、TLS、稽核記錄、限流、外掛熱重載、CI 假後端。 | +| **MCP 伺服器** | `je_auto_control_mcp`(stdio)或 `AC_start_mcp_http_server` | 683 個工具,供 Claude Desktop/Claude Code/自訂 tool loop 使用:完整的 `ac_*` 工具介面,以及常用 GUI 操作的短名稱 alias。除了以 `initialize` 握手的各版協定,也支援無狀態的 MCP 2026-07-28。Bearer 驗證、TLS、稽核記錄、限流、外掛熱重載、CI 假後端。 | | **REST API** | `je_auto_control start-rest` | Bearer token、逐 IP 限流與鎖定、SQLite 稽核 hook、`/metrics`、`/openapi.json`、`/docs` Swagger UI、`/dashboard`。 | | **TCP socket 伺服器** | `je_auto_control start-server` | 以換行分隔的 JSON 動作清單。預設綁 `127.0.0.1`。 | | **pytest 外掛** | 安裝後自動生效 | 提供 fixture 與供 pytest-bdd/behave 使用的 Gherkin step library。 | diff --git a/architecture.md b/architecture.md index 33f844464..0a428e081 100644 --- a/architecture.md +++ b/architecture.md @@ -16,6 +16,13 @@ macOS, Linux X11, Linux Wayland, Android and iOS. Each layer calls only the one below it: entry points → execution core (`utils/executor/`) → headless capabilities (`utils/`) → `wrapper/` → one OS backend. +Fixed-frame self-healing evaluation is a separate headless flow in `utils/self_healing/`: +bounded manifest/PNG inputs → immutable samples → explicit locator strategies → +quality/latency/provenance report → optional atomic JSON/HTML export. It performs +no capture or input. Python VLM comparisons require an explicit backend; local +manifest commands never choose one. API, AC, MCP and a cancellable GUI worker use +the same evaluator. Live self-healing remains on its existing runtime path. + | Path | Responsibility | | --- | --- | | `je_auto_control/__init__.py` | Facade: re-exports the public API and lists it in `__all__`. Must import without PySide6. | diff --git a/architecture_explore.md b/architecture_explore.md index 8ce447ef6..627077343 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,14 +19,14 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,096 | -| 程式碼總行數 | 164,150 | +| Python 模組總數(含周邊子專案) | 1,104 | +| 程式碼總行數 | 165,357 | | `je_auto_control/utils/` 子套件數 | 311 | -| `AC_*` 動作指令數(`known_commands()` 實測) | 782 | -| 套件門面 `__all__` 公開名稱數 | 1,279 | +| `AC_*` 動作指令數(`known_commands()` 實測) | 783 | +| 套件門面 `__all__` 公開名稱數 | 1,292 | | GUI 分頁數(`main_widget` 註冊) | 48 | -| MCP 工具數(`build_default_tool_registry()` 實測) | 682 | -| `test_*.py` 測試檔/測試函式 | 771 / 8,127 | +| MCP 工具數(`build_default_tool_registry()` 實測) | 683 | +| `test_*.py` 測試檔/測試函式 | 775 / 8,159 | | 範例腳本 | 30 | **技術基線**:Python ≥ 3.10、MIT 授權、必要相依只有 `je_open_cv`/`opencv-python`/`pillow`/`mss`/ @@ -92,7 +92,7 @@ USB/IP 協定、Prometheus 指標),以維持這條輕相依基線。 | 模式 | 落點 | 說明 | | --- | --- | --- | | **Strategy** | `wrapper/platform_wrapper.py` | 依 `sys.platform` 只匯入當前 OS 的 `keyboard`/`mouse`/`screen`/`recorder` 實作;Linux 再細分 Wayland/X11,Wayland 後端不可用時自動退回 XWayland 並記警告。新增平台不需要改 wrapper。 | -| **Facade** | `je_auto_control/__init__.py` | 把 1,279 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | +| **Facade** | `je_auto_control/__init__.py` | 把 1,292 個公開名稱集中再匯出,使用者只 `import je_auto_control`。`api/core.py` 另提供一個小而穩定的版本化門面(7 個名稱)給新整合使用。 | | **Command** | `utils/executor/action_executor.py` | `Executor.event_dict` 是字串 → callable 的分派表;JSON 動作檔即指令序列,因此可錄製、序列化、重播、簽章。 | | **Observer** | `utils/callback/`、`utils/observer/`、`utils/triggers/` | 動作完成後觸發回呼;畫面出現/消失/變化與外部事件(webhook/IMAP/檔案)驅動腳本。 | | **Template Method** | `utils/generate_report/` | HTML/JSON/XML 三個產生器共用「收集紀錄 → 格式化 → 寫檔」骨架,各自實作渲染。 | @@ -154,7 +154,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `je_auto_control/__init__.py` | 2,003 | **套件門面**。集中匯入並再匯出 1,279 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | +| `je_auto_control/__init__.py` | 2,009 | **套件門面**。集中匯入並再匯出 1,292 個公開名稱,以功能區塊註解分段(callback/exception/executor/a11y/vision/clipboard…)。 | | `je_auto_control/__main__.py` | 106 | 舊版 argparse 進入點:`-e` 執行單檔、`-d` 執行整個目錄、`--execute_str` 執行 JSON 字串、`-c` 建立專案。 | | `je_auto_control/cli.py` | 437 | **主 CLI**(`je_auto_control` console script)。子命令:`run`(含 `--var`/`--dry-run`/`--journal`)、`journal`(依 run 篩選與有界遮罩快照)、`validate`/`lint`、`list-commands`、`fmt`、`record`、`codegen`、`failure-bundle`、`list-jobs`、`start-server`、`start-rest`、`version`。所有子命令延遲匯入,確保不碰 Qt。 | | `je_auto_control/api/__init__.py` | 22 | 版本化整合進入點。 | @@ -272,7 +272,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.1 執行引擎與腳本資產 -> 25 個套件、約 16,085 行。 +> 25 個套件、約 16,087 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -284,7 +284,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/decision_table/` | 112 | DMN 風格決策表:規則 + 命中策略,把分支外部化 | | `utils/deterministic/` | 116 | 決定性執行控制:固定亂數種子 + 凍結時鐘 | | `utils/action_journal/` | 780 | 結構化動作日誌:schema v1、寫入前遮罩、跨行程排序/尾端復原、executor 明確 scope 擷取與本機 parallel/DAG parent 傳遞;門面、AC/MCP、Script Builder 與 CLI 擷取/有界查閱;observer/路徑政策隨巢狀根與 workers 保留,專用 GUI/手動 history 待補。 | -| `utils/executor/` | 9,707 | **核心**。`Executor` 指令分派表(781 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | +| `utils/executor/` | 9,709 | **核心**。`Executor` 指令分派表(781 個 `AC_*`)、參數插值、乾跑、逐步 callback;`flow_control` 提供 34 個區塊指令(迴圈/分支/try/巨集/變數) | | `utils/flow_debugger/` | 166 | action list 的單步除錯器與追蹤器 | | `utils/input_macro/` | 462 | 定時輸入事件:錄製結果的整形(`timeline`/`InputRecorder`,Windows 與 macOS 共用)、重播與宣告式輸入序列 DSL | | `utils/json/` | 99 | action JSON 檔讀寫與正規化格式化(`fmt --check` 的後端) | @@ -465,7 +465,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.8 元素定位、自我修復與智慧等待 -> 23 個套件、約 4,438 行。 +> 23 個套件、約 5,163 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -488,14 +488,14 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/observation_delta/` | 122 | token 預算內的觀察差異:兩個 UI 影格之間變了什麼 | | `utils/screen_state/` | 191 | 語義畫面狀態:快照/差異與結構化畫面描述 | | `utils/scroll_find/` | 103 | 捲動直到目標影像/文字可見 | -| `utils/self_healing/` | 371 | 自癒定位器:先影像樣板、失敗改用 VLM,並留稽核記錄 | +| `utils/self_healing/` | 1,096 | 執行時自癒定位與固定 PNG 版本比較、明確母體/延遲/來源報告 | | `utils/semantic_recording/` | 498 | 為錄製內容加上語義錨點,支援換機重播與自癒重播 | | `utils/settle_detector/` | 79 | 以純函式介面判定 UI 是否已靜止 | | `utils/smart_waits/` | 672 | 智慧等待:以影格差異取代 `time.sleep` | ### 5.4.9 AI / Agent / LLM -> 13 個套件、約 23,845 行。 +> 13 個套件、約 23,886 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -508,7 +508,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/cua_action/` | 204 | 標準化 computer-use 動作結構(Anthropic/OpenAI → `AC_*`) | | `utils/llm/` | 372 | 自然語言 → action list 規劃器 + Anthropic/null 後端;規劃執行隔離頂層變數並沿用巢狀範圍 | | `utils/mcp_registry/` | 97 | MCP registry `server.json` 資訊清單產生(可被發現) | -| `utils/mcp_server/` | 19,323 | **無頭 MCP 伺服器**(預設註冊 682 個工具=663 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | +| `utils/mcp_server/` | 19,364 | **無頭 MCP 伺服器**(預設註冊 683 個工具=664 個 `ac_*` + 19 個別名):stdio + HTTP 傳輸、工具工廠與處理器、資源、prompt、稽核、限流、外掛熱重載 | | `utils/tool_use_schema/` | 195 | 把 `AC_*` 指令匯出成 Claude/OpenAI 的 tool-use schema | | `utils/trajectory_eval/` | 132 | agent 軌跡評估:依評分規準為一次執行打分 | | `utils/vision/` | 538 | VLM 元素定位器(依描述找元素)+ Anthropic/OpenAI/null 後端 | @@ -631,7 +631,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 ### 5.4.14 安全、機密與合規 -> 13 個套件、約 3,528 行。 +> 13 個套件、約 3,530 行。 | 模組 | 行數 | 職責 | | --- | ---: | --- | @@ -640,7 +640,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `utils/governance/` | 242 | 治理:maker-checker 核准閘門與即時憑證租約 | | `utils/license_policy/` | 240 | 以 SBOM 元件評估 SPDX 授權允許/拒絕政策 | | `utils/provenance/` | 126 | SLSA 建置來源證明(in-toto v1) | -| `utils/rbac/` | 795 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | +| `utils/rbac/` | 797 | 角色型存取控制:使用者與權杖驗證、REST/MCP/executor 的能力政策,`deferred.py` 將背景工作綁定註冊者並在觸發時重新驗證來源 store;不保存 bearer token。;MCP 敏感讀取獨立於唯讀提示要求 operator | | `utils/redaction/` | 508 | 截圖遮蔽層:規則偵測 + 政策 + 協調器(上傳 VLM 前先遮) | | `utils/sbom/` | 170 | SBOM(CycloneDX)產生 | | `utils/secret_ref/` | 238 | URI scheme 形式的值參照解析 | @@ -697,11 +697,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 上表以子套件為單位;以下把行數最大的幾個子系統展開到檔案層。 -#### `utils/executor/`(9,707 行)— 執行核心 +#### `utils/executor/`(9,709 行)— 執行核心 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `action_executor.py` | 8,452 | `Executor` 類別與 `event_dict` 分派表(781 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | +| `action_executor.py` | 8,454 | `Executor` 類別與 `event_dict` 分派表(781 個指令),另含數百個把 utils 能力接成指令的 adapter 函式;全域單例 `executor` 與 `add_command_to_executor()` 擴充點。 | | `flow_control.py` | 678 | 真正的流程控制:`AC_loop`/`AC_for_each`/`AC_while_*`/`AC_if_*`/`AC_try`/`AC_retry`/`AC_parallel`/`AC_define_macro`/`AC_call_macro`/變數指令(`AC_set_var`/`AC_get_var`/`AC_inc_var`)。`LoopBreak`/`LoopContinue` 以例外實作。34 個區塊指令的分派表 `BLOCK_COMMANDS` 也在這裡,含下一列匯入的資料來源指令。 | | `flow_data_commands.py` | 272 | `AC_*_to_var` 資料來源與轉換指令:shell、時鐘、亂數、PDF、TOTP、SQL、檔案、HTTP、OCR,加上 `AC_assert_var`/`AC_assert_db`/`AC_assert_duration`/`AC_transform_var`。都不執行巢狀 action list,所以沒有迴圈/分支語意。 | | `action_schema.py` | 161 | action list 的結構驗證:形狀、參數型別、未知指令拒絕。單一走訪同時支援兩種消費方式:`validate_actions()` 遇到第一個問題就拋、`unknown_command_names()` 收齊全部不認得的名字(REST `/execute` 用它回 400)。 | @@ -709,11 +709,11 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `mouse_aliases.py` | 39 | 單鍵點擊別名(`AC_click_left` 等),executor 與 callback executor 共用。 | | `flags.py` | 19 | 旗標讀取 `as_bool`:依拼法(`"true"`/`"yes"`/`"on"`/`"1"`)而非 Python 真值判斷,所以 JSON、CLI、MCP 傳來的 `"false"` 是關;所有 adapter 與流程指令共用。 | -#### `utils/mcp_server/`(19,323 行,682 個工具)— 最大子系統 +#### `utils/mcp_server/`(19,364 行,683 個工具)— 最大子系統 | 檔案 | 行數 | 職責 | | --- | ---: | --- | -| `tools/_factories.py` | 9,106 | 工具工廠:每個函式回傳一個領域的 `MCPTool` 清單(把 `AC_*` 能力包成 MCP 工具)。 | +| `tools/_factories.py` | 9,107 | 工具工廠:每個函式回傳一個領域的 `MCPTool` 清單(把 `AC_*` 能力包成 MCP 工具)。 | | `tools/_handlers.py` | 547 | 把 MCP 工具呼叫橋接到 AutoControl 無頭 API 的 adapter;主題模組拆完之後這裡留的是資料/文字/HTTP 那一類與 WebRunner 橋接。 | | `tools/_handlers_qa.py` | 419 | 同一種 adapter,QA 主題:斷言 DSL、資料驅動、SQL/PDF/郵件/HTTP 步驟、codegen、視覺回歸、狀態機、flaky 偵測與隔離、suite runner、無障礙稽核、裝置矩陣、媒體斷言。從 `_handlers.py` 依主題拆出的第一塊(750 行上限);兩者互不引用。 | | `tools/_handlers_input.py` | 221 | 同一種 adapter,輸入主題:滑鼠、鍵盤、虛擬手把(ViGEm)。 | @@ -834,6 +834,18 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `rest_registry.py` | 81 | 保存執行中 REST 伺服器的行程級單例,狀態包含 RBAC 模式並隱去停用的共用 token。 | | `__main__.py` | 66 | `python -m je_auto_control.utils.rest_api` 進入點。 | +#### `utils/self_healing/`(1,096 行)— 執行時定位與固定影像比較 + +| 模組 | 行數 | 職責 | +| --- | ---: | --- | +| `utils/self_healing/__init__.py` | 38 | 定位、比較模型、策略與報告的公開介面。 | +| `utils/self_healing/locator.py` | 230 | 執行時樣板優先/VLM 後備;取消原樣傳遞。 | +| `utils/self_healing/heal_log.py` | 115 | 相容既有格式的 JSONL 記錄與執行中秘密遮蔽。 | +| `utils/self_healing/evaluation_models.py` | 235 | 不可變影像樣本、候選、母體與來源模型。 | +| `utils/self_healing/evaluation.py` | 175 | 固定 frame 比較;標註、驗證、誤判、成本及延遲分開統計。 | +| `utils/self_healing/frame_locators.py` | 141 | 純影像比對與明確 VLM backend;共用區域、原點與縮放換算。 | +| `utils/self_healing/evaluation_io.py` | 162 | 有界且受路徑政策限制的本機資料集、原子 JSON/HTML 報告。 | + #### 其他多檔子套件 | 子套件 | 檔案組成 | @@ -858,7 +870,7 @@ socket server 有 8 MiB 讀取上限與 30 秒 handler timeout。 | `time_travel/` | `controller.py`、`player.py` | | `dag/` | `graph.py`、`runner.py` | | `run_history/` | `history_store.py`、`context.py`(非擁有式 journal 連結與 worker 擁有者)、`manual.py`/`commands.py`(明確手動 run、正常 loop 訊號與原始結果保留)、`artifact_manager.py`、`run_outcome.py`(一般失敗計數、中止分類與保留硬中止的歷史結算) | -| `self_healing/` | `locator.py`、`heal_log.py` | +| `self_healing/` | `locator.py`、`heal_log.py`、`evaluation*.py`、`frame_locators.py`;檔案級表見上。 | | `ab_locator/` | `runner.py`、`store.py` | | `cost_telemetry/` | `pricing.py`、`store.py` | | `governance/` | `governance.py`、`credential_broker.py` | @@ -904,7 +916,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `journal_browser.py` | 221 | Run History 內的唯讀日誌檢視:來源連結/路徑/run 篩選、有界快照表與明細,舊結果以 request ID 隔離。 | | `journal_browser_worker.py` | 84 | 背景查閱與逐筆取消;帶入 context 並在開檔前重查 deferred 擁有者,以 queued counts 回報進度。 | | `manual_run_controls.py` | 53 | 手動執行的 history 勾選與獨立 journal 路徑;以共用無頭 scope 包住既有 GUI 執行,不改結果與變數。 | -| `language_wrapper/` | 5,183 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | +| `language_wrapper/` | 5,296 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | | `selector/` | 216 | 拖曳選取螢幕區域的半透明全螢幕覆蓋層與樣板裁切工具(互動式,但都有對應的程式化 API)。 | > **分頁指令一律走 Actions 選單**:分頁本身只放輸入、表格與結果檢視,指令由視窗層選單暴露。 @@ -919,14 +931,14 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | screenshot | `_screenshot_tab.py` | 137 | 截圖、選區、螢幕尺寸、取像素色。 | | image_detect | `_image_detect_tab.py` | 115 | 樣板裁切、定位、定位全部、定位並點擊。 | | record | `_record_tab.py` | 114 | 錄製/停止/回放/存檔/載入。 | -| script_builder | `script_builder/` | 6,020 | **視覺化腳本編輯器**:`command_schema.py`(5,170 行 `AC_*` 參數綱要)、`step_model.py`(步驟模型與 AC JSON 序列化)、`step_list_view.py`(含巢狀 body 的樹狀檢視)、`step_form_view.py`(綱要驅動表單)、`builder_tab.py`。 | +| script_builder | `script_builder/` | 6,025 | **視覺化腳本編輯器**:`command_schema.py`(5,175 行 `AC_*` 參數綱要)、`step_model.py`(步驟模型與 AC JSON 序列化)、`step_list_view.py`(含巢狀 body 的樹狀檢視)、`step_form_view.py`(綱要驅動表單)、`builder_tab.py`。 | | flow_editor | `flow_editor/` | 490 | 節點式流程圖檢視:`layout.py`(純 Python 佈局演算法,可單測)、`scene.py`(Qt 場景繪製)、`tab.py`。 | | script | `_script_tab.py` | 124 | 載入/執行單檔或整個目錄、內建編輯器執行。 | | recording_editor | `recording_editor_tab.py` | 244 | 裁切、過濾、重新縮放錄製內容。 | | variables | `variables_tab.py` | 169 | 檢視、灌入、清除 executor 執行期作用域。 | | secrets | `secrets_tab.py` | 187 | 解鎖保險庫並管理 `${secrets.NAME}`。 | | vlm | `vlm_tab.py` | 113 | 用文字描述 UI 元素,交由模型定位。 | -| self_healing | `self_healing_tab.py` | 195 | 自癒定位器管理與治癒記錄。 | +| self_healing | `self_healing_tab.py`、`healing_evaluation_panel.py`、`healing_evaluation_worker.py` | 486 | 執行時定位/記錄與固定影像版本比較;背景工作可取消並丟棄過期結果。 | | ocr_reader | `ocr_tab.py` | 169 | 傾印區域文字或以 regex 搜尋。 | | accessibility | `accessibility_tab.py` | 153 | 瀏覽 OS UI 樹並依 role/name 點擊。 | | live_hud | `live_hud_tab.py` | 128 | 即時 HUD:滑鼠位置、游標下像素色、log tail。 | @@ -1004,7 +1016,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6 | `scripts/dev_release.py` | 只用標準函式庫的發佈輔助腳本 | dev 通道(PyPI 的 `je_auto_control_dev`)的版本與比對:`prepare` 把 `dev.toml` 寫成 `pyproject.toml`,版本取 PyPI 最新一版加一個 patch(`dev.toml` 的版本只是下限,不回寫 repo);`changed dist` 把建好的 wheel 跟 PyPI 最新一版逐檔比對,內容沒變就不上傳。只給 `dev.yml` 的 `publish-dev` job 與拋棄式的 checkout 用,因為 `prepare` 會蓋掉 `pyproject.toml`。 | | `docs/` | Sphinx(`API`/`Eng`/`Zh`/`getting_started`) | Read the Docs 文件。 | | `architecture_diagram/` | drawio + png | 既有的架構圖原始檔。 | -| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 771 個 `test_*.py`/8,127 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | +| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 775 個 `test_*.py`/8,159 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 | --- @@ -1086,12 +1098,12 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 103 | 29,327 | -| `utils/mcp_server/` | 38 | 19,323 | +| `gui/` | 105 | 29,736 | +| `utils/mcp_server/` | 39 | 19,364 | | `utils/remote_desktop/` | 56 | 13,301 | -| `utils/executor/` | 8 | 9,707 | +| `utils/executor/` | 8 | 9,709 | | `utils/usb/` | 17 | 4,816 | -| `je_auto_control/`(頂層 3 檔) | 3 | 2,546 | +| `je_auto_control/`(頂層 3 檔) | 3 | 2,552 | | `utils/accessibility/` | 14 | 3,143 | | `wrapper/` | 19 | 4,099 | | `windows/` | 23 | 2,154 | @@ -1106,6 +1118,6 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `osx/` | 17 | 981 | | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 861 | -| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 702 | 59,998 | -| **總計** | **1,090** | **164,085** | +| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 706 | 60,725 | +| **總計** | **1,097** | **165,270** | diff --git a/benchmarks/self_healing/README.md b/benchmarks/self_healing/README.md new file mode 100644 index 000000000..d08e6b910 --- /dev/null +++ b/benchmarks/self_healing/README.md @@ -0,0 +1,30 @@ +# Fixed-frame locator comparison + +Run from the repository root with the project's Python environment: + +```python +from je_auto_control import evaluate_healing_manifest, write_healing_report + +report = evaluate_healing_manifest("benchmarks/self_healing/dataset.json") +write_healing_report(report, "locator-comparison.json") +write_healing_report(report, "locator-comparison.html") +``` + +The five synthetic PNG samples cover an old template, a changed template, a negative origin with 2× scale and a region, an expected miss, and an unlabelled frame. `acceptance.json` fixes the expected populations: old 2/4 correct, new 3/4, one unknown each, no false positives. This tiny regression dataset does not establish accuracy on real applications. Rebuild its PNGs with `python benchmarks/self_healing/build_fixtures.py`; `Random` seeds and pixel dimensions are fixed. + +The same report is available through `AC_self_heal_evaluate` with `{"path":"benchmarks/self_healing/dataset.json"}`, MCP `ac_self_heal_evaluate`, Script Builder, and **Self-Healing → Version comparison**. The CLI can execute an action JSON file containing that command. GUI operations live in Actions; comparison runs in a cancellable worker and changing the manifest discards old results. JSON and standalone HTML exports contain case labels, coordinates, frame hashes and run/step provenance, but no PNG payloads. + +Manifest schema 1 requires `samples` and `versions`. A sample has `id`, relative `frame`, optional `expected_box`, `expected_miss`, `origin`, `scale`, `region`, `run_id`, `step_id`. Boxes are half-open `[left, top, right, bottom]` in logical screen units; `scale` is pixels per logical unit. A box and expected miss cannot coexist. No label means unknown. Region pixels are cropped identically for both strategies; transformed pointer coordinates are floored. A version has a relative `template` PNG and optional `threshold` (default 0.9). Absolute assets, directory traversal and symlink escapes are refused. Explicit/enclosing path policies intersect. Limits: 1 MiB manifest, 16 MiB/16 million pixels per PNG, 64 MiB encoded assets total, 500 samples, 32 versions and 10,000 sample/version pairs. Unknown fields, duplicate JSON keys, invalid numbers and constant templates fail explicitly. No capture, input or network backend is selected by this manifest operation. + +For custom Python comparisons, use `EvaluationSample`, `TemplateFrameLocator` and `evaluate_locators(samples, versions)`. `VLMFrameLocator` requires an explicitly supplied `VLMBackend`; it never calls `get_backend`. Its coordinates are local PNG pixels and use the same region/origin/scale transform. An optional template fallback receives the identical immutable sample. Tests use a controlled backend; no paid service acceptance is claimed. Backend exceptions and cancellation propagate without becoming misses or partial reports. + +Every ratio includes numerator, denominator and a null value for an empty eligible population: + +| Metric | Numerator | Denominator | +| --- | --- | --- | +| Accuracy | Correct point in labelled box, or a correct expected miss | Labelled samples | +| False positive | Wrong detected point or unexpected detection | Labelled samples returning coordinates | +| Recovery | Correct detected point **and** explicit `verified=True` | Fallback attempts with a known label and explicit verification result | +| Image hit / fallback / miss | Corresponding observed strategy outcome | All attempts | + +Unlabelled samples never count as correct. A model point or an expected miss cannot count as recovery. Missing labels/verification remain explicit unknowns. `verification_unknown` counts fallback attempts missing either a ground-truth label or an explicit verification result; `unknown` counts unlabelled samples independently. Verification is evidence supplied by the caller; these offline adapters never perform an operation or invent verification. Optional costs are USD, with the known-cost population shown; unavailable costs stay null. p50/p95 use linearly interpolated wall time across the samples, including PNG decoding and initial lazy imports, with no warm-up or machine-normalization. These small samples do not support a latency improvement claim. Per-strategy timings retain nested fallback names. Reports mask already-resolved run-local secret strings without changing caller inputs. diff --git a/benchmarks/self_healing/acceptance.json b/benchmarks/self_healing/acceptance.json new file mode 100644 index 000000000..0dc81d1af --- /dev/null +++ b/benchmarks/self_healing/acceptance.json @@ -0,0 +1,4 @@ +{ + "old": {"correct": 2, "labelled": 4, "unknown": 1, "false_positive": 0}, + "new": {"correct": 3, "labelled": 4, "unknown": 1, "false_positive": 0} +} diff --git a/benchmarks/self_healing/build_fixtures.py b/benchmarks/self_healing/build_fixtures.py new file mode 100644 index 000000000..cdcf050ac --- /dev/null +++ b/benchmarks/self_healing/build_fixtures.py @@ -0,0 +1,22 @@ +"""Rebuild deterministic synthetic comparison PNGs without device access.""" +from pathlib import Path +from random import Random + +from PIL import Image + + +def build(destination: Path) -> None: + """Write two template revisions, two target frames and an expected-miss frame.""" + destination.mkdir(parents=True, exist_ok=True) + for version, seed in (("old", 41), ("new", 42)): + with Image.frombytes("RGB", (17, 13), Random(seed).randbytes(17 * 13 * 3)) as template: + template.save(destination / f"{version}.png") + with Image.new("RGB", (64, 48), (240, 240, 240)) as frame: + frame.paste(template, (20, 16)) + frame.save(destination / f"frame_{version}.png") + with Image.new("RGB", (64, 48), (240, 240, 240)) as frame: + frame.save(destination / "absent.png") + + +if __name__ == "__main__": + build(Path(__file__).parent / "fixtures") diff --git a/benchmarks/self_healing/dataset.json b/benchmarks/self_healing/dataset.json new file mode 100644 index 000000000..a80594360 --- /dev/null +++ b/benchmarks/self_healing/dataset.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "samples": [ + {"id": "legacy", "frame": "fixtures/frame_old.png", "expected_box": [20, 16, 37, 29], + "run_id": "fixture-run", "step_id": "legacy-submit"}, + {"id": "changed", "frame": "fixtures/frame_new.png", "expected_box": [20, 16, 37, 29], + "run_id": "fixture-run", "step_id": "changed-submit"}, + {"id": "negative-origin-2x-region", "frame": "fixtures/frame_new.png", + "origin": [-80, -40], "scale": 2, "region": [-72, -34, -60, -24], + "expected_box": [-70, -32, -61.5, -25.5], "run_id": "fixture-run", "step_id": "scaled-submit"}, + {"id": "expected-miss", "frame": "fixtures/absent.png", "expected_miss": true}, + {"id": "unlabelled", "frame": "fixtures/frame_new.png"} + ], + "versions": { + "old": {"template": "fixtures/old.png", "threshold": 0.99}, + "new": {"template": "fixtures/new.png", "threshold": 0.99} + } +} diff --git a/benchmarks/self_healing/fixtures/absent.png b/benchmarks/self_healing/fixtures/absent.png new file mode 100644 index 0000000000000000000000000000000000000000..6545c4b164b10a45ee83eabe832ca696b10b0fe1 GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^4nSFSSI;>LGAJ+{-VpRZ tNu5#9W`~>OIkQtLevc?ee`G648Mi!PHYnb3LLX=&gQu&X%Q~loCIBL4H>Cgo literal 0 HcmV?d00001 diff --git a/benchmarks/self_healing/fixtures/frame_new.png b/benchmarks/self_healing/fixtures/frame_new.png new file mode 100644 index 0000000000000000000000000000000000000000..f1b3ea315d0fd72ba64b50718f326bb756b3d489 GIT binary patch literal 976 zcmeAS@N?(olHy`uVBq!ia0vp^4nSk?NSL#k4Vk?RDmG8R?V=II=93hQp*gtdkT4_!vpnv^!X^96}TNV9EZX@9tXN4vUBXgO(^{rmlO?OS$33>;>P!yVdM?!1=KTgS zxhMM*mRV@atG_A=x;@F@|8=po{&&Si+miY>XwO<7)DXtFaF5D;|Z4tY`a$39JoPZE6ZdEZ`b*O$`7&krKj@?X$W=s)~5 zy!G3X>Cd@ycjb1Q3ayG0TixmT;ri;MGEaqCn?H31?<|}kZnrDvPvA89)z|h2SvV|a znD0`%RMB}tl2e-rAre>0VRYwMxvW`&O#=5KhfSO5BuuSC~m4WFfcuBw~n z{psAs@b2dR=EP-zmw$?9O?g#Z?yGW}fA{NI+jM+(|4caYb-QU}@%*^;Tt64s$HnD` z-+%r6lG(K*k#Y*R0=}FU+vti3+VFKp`JhurzfMzb@vG|sSTF3)?gbjqEI>caiAgSSmh z+9vL{#Yjwdmd}(q-ErUd9-5S+uT`ALbZdD&T(#Nfnh zOOBe$aItBLc3+WnbUo~FX=kKt)7rJE$@MHMb@|(#f1iBE|J=^+YRB`El9)W&94uH4 zDGKm47I3sUE66ZC6yRt_6*^RJ>5wtgPd0#?Ue^U67|L^}RbV%+_hON?lF}o7?71H-Y z{&cP}4c`ATNbLUc$HgZLs{LFpuk-x1%W?H$o!##yUe6E=&}#Tl{(EWBEJX`_yZ-Bu zk7h|N4VEr-GE`rr;1`&D|L>t?9$i{BXgO@oa+@tYeM#R# zw!6F21#4#|*Jk|-+P(c$(PizzoU=+7%TBL2m$>oBu9zu%!zxdoyqc!P?!~H6mD`p2 z`Re(WXLnWK+H#xf@BXCxd1})8soxL(uD8rv@NxOeQx86!@R~k1XXo>)PajOK7kxJC zZq7ELQ!g&;TX5v}YW8TKNk3ojPv5tq|KF>LeZMb#){k4Ya@GH@(zQL-yN`yh`Tl-x z-_-IL{wpP0JiRr)aLL{^s=Hy{`@U|@U#$l(4qjdPY(mom(T6;~M_XU;x3#o)_3g=x zdo{;C^x!N1pSd-M*PHo8OM3igeARQINnR!I*pm&P>k4O!^5o?o(EcjQ*%ijA)$PkH znfm7R=UYiC?+#Bmu`)nJ z?zZ8YuMwX=eDHDYS4(&zwRl}wj#i?LEGysnHK)HZE`8@P-%(DwBq=z5-lZ8|CVt)g zMg7uN*Y_$Pj|Ol0c3fQd_c{CWN$0$)-`vEHQqiy?grHQ!h%2s%N>C7x=B47hA`H+-2IlKJ`M^UfW%JX6A<5Y-fM<*!jF^<|e;1yYwSlQ|7PT{@P)P4smN4s5MJc^%pYUJ-ZCSFM6&$XWu9pT(LZwgaWOoE$u? zgg9OTIvUsoOb-#R-jhY~5ILN3xdyz+^rgtqL$7zfCF&j1f?IoD^N6S7v&3o{Bc*r( zg-tgE2FfW9-thcdQlKlHttFa)OHgbW0W^ul$qzN>aJ@#C+}0hP+&*Y)Zt^OqKa|Ak z^0OlZh!PYmcsku{qK+y5tzRpIO98uCO@vBREV6q(5_c|h3U|;DqPglCJ@GK~&U`2i zv&uFEBUiGgxNIb4u160BK#(1_y(j_y8Yx;=su~f}J!sQ?t_ZDn6FYxqXZ`(Uz0EoZ zapUIz8ur@wE|@#A-_hOBI@=l7^G{(pM=3{>vJ!MJH@u?WLWtEtq-9;{vZE;oF2r6_ z1@l?~u0W!PWcOPK>=>=TRwib(%kCAXKr#*1v8gxuvWlX+1iRiS;s5f#5-}|*NtKd; za8Muty36hHa{?2VI<(s@1W2c?YW69*QkqK)7d#&6O1U8Gd%lW@?ARCNRpcM45`3u& zfS&IJN{F~1hcc*Mhf)VV;};Np@Bs$NltIS@Cnx+@OPNSSbPAhr5meL+BqZW;Bo&}C zLXq$R_yRWQM~w-a#CZ+3R6$cXZpiX4;pym|@z;5^BOb?OW|WfM)F#d{RLPYrP!*NU zP(6hKvhb1exxdrQGsGUDqnxW{?g4uSC_Mj1A9r}A;v4rAmbGgkQVzO@{t5ZHEivdE aX$IKpKR3!Lk1=8Z0000P)Qv|~aSg}#SOef_ zp}tK7EOqQov=NZ$;%7GBrWT(~*7Qd@7N*~-7hJ{Ay-W7{06!ftr>&bF;tf{GABf77 zo0F&ju>_eYd~mPX_M!1m#5OT z@~{H}QRf9Bngix8a!GPJzl9WEJj}A}%u!`BIeAE%lLf=;`z6)r*~~TV8}B+_^^VQ)^w2*TEU6S_&?LwFMw-06 zCdIq}BY_2@qR^?jUk|9!l}3JJJP6!ZHxp(2wfj1|(u)OA4g3`^35+7$7Vi`G)LM0@ zj2rR-5S?ci>{vFeBS=u22Q|kf?y|RM0$eytjfC~jW{E7e3e8Efo;iai*6gCO1mr`` zJO6Y5Qc3M2En+W;Bhgi%&O@)$5GWs6xB2@b3Yc$VxD&n0-0nS_u3I_f-zb?0+KIqf zz(m#ny4H#S{Toljjr_3U7O5V#D1PBdSkn*t>_&k)vE5Ohpev6kA zg=I1T!?CM%&~Fj089~>$Q0yIP3GnvG4K6fusQ^C}+@QAiAWS8zBO;_mJN?LtEBz)m a&DQN^C`~tTZib@(0000 Dict[str, Any]: """Executor adapter: locate with self-heal, then click.""" +def AC_self_heal_evaluate(path: str) -> dict[str, object]: + """Executor adapter shared by scripts, CLI and server action execution.""" + def AC_self_heal_locate( template_path: str | None = ..., description: str | None = ..., diff --git a/je_auto_control/gui/healing_evaluation_panel.py b/je_auto_control/gui/healing_evaluation_panel.py new file mode 100644 index 000000000..76469af6d --- /dev/null +++ b/je_auto_control/gui/healing_evaluation_panel.py @@ -0,0 +1,215 @@ +"""Fixed-frame version summaries, failure cases and provenance inside Self-Healing.""" +from __future__ import annotations + +import json +from dataclasses import asdict +from typing import Callable, Optional + +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QCloseEvent +from PySide6.QtWidgets import ( + QAbstractItemView, QFileDialog, QFormLayout, QLabel, QLineEdit, QPlainTextEdit, + QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui._worker_thread import WorkerHandle, start_worker +from je_auto_control.gui.healing_evaluation_worker import HealingEvaluationOutcome, HealingEvaluationWorker +from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.rbac.policy import authorize_command +from je_auto_control.utils.self_healing.evaluation import HealingComparison +from je_auto_control.utils.self_healing.evaluation_models import EvaluationRatio, HealingAttempt, HealingVersionReport +from je_auto_control.utils.self_healing.evaluation_io import write_healing_report + +_SUMMARY = ("version", "accuracy", "false_positive", "recovery", "unknown", "p50", "p95") +_CASES = ("version", "sample", "correct", "run_id", "step_id") + + +def _t(key: str) -> str: + return language_wrapper.translate("healing_eval_" + key) + + +def _table(columns: tuple[str, ...]) -> QTableWidget: + table = QTableWidget(0, len(columns)) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + return table + + +def _populate(table: QTableWidget, rows: list[list[str]]) -> None: + table.setRowCount(len(rows)) + for index, row in enumerate(rows): + for column, value in enumerate(row): + table.setItem(index, column, QTableWidgetItem(value)) + table.resizeColumnsToContents() + + +def _ratio(ratio: EvaluationRatio) -> str: + return f"{ratio.numerator}/{ratio.denominator}" if ratio.denominator else _t("unknown") + + +def _version_row(name: str, version: HealingVersionReport) -> list[str]: + ratios = [_ratio(ratio) for ratio in (version.accuracy, version.false_positive, version.recovery)] + latency = [_t("unknown") if value is None else f"{value:.3f}" for value in (version.p50_ms, version.p95_ms)] + return [name, *ratios, str(version.unknown), *latency] + + +def _case_row(row: HealingAttempt) -> list[str]: + correct = _t("unknown") if row.correct is None else _t("correct_true" if row.correct else "correct_false") + return [row.version, row.sample_id, correct, row.run_id or "", row.step_id or ""] + + +# pylint: disable-next=too-many-instance-attributes # reason: owned view controls and one cancellable request +class HealingEvaluationPanel(TranslatableMixin, QWidget): + """Run comparisons through Actions; show explicit populations and selected case details.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._generation = 0 + self._handle: Optional[WorkerHandle] = None + self._worker: Optional[HealingEvaluationWorker] = None + self._report: Optional[HealingComparison] = None + self._path = QLineEdit() + self._summary, self._cases = _table(_SUMMARY), _table(_CASES) + self._details = QPlainTextEdit() + self._details.setReadOnly(True) + self._status = QLabel() + self._status.setTextFormat(Qt.TextFormat.PlainText) + self._status.setWordWrap(True) + self._state, self._error = "idle", "" + root = QVBoxLayout(self) + form = QFormLayout() + form.addRow(self._tr(QLabel(), "healing_eval_path"), self._path) + root.addLayout(form) + root.addWidget(self._tr(QLabel(), "healing_eval_explanation")) + views = QSplitter(Qt.Orientation.Vertical) + views.addWidget(self._summary) + cases = QSplitter(Qt.Orientation.Horizontal) + cases.addWidget(self._cases) + cases.addWidget(self._details) + views.addWidget(cases) + root.addWidget(views, stretch=1) + root.addWidget(self._status) + # pylint: disable-next=no-member # reason: PySide binds the Qt signal at runtime + self._path.textChanged.connect(self._path_changed) + # pylint: disable-next=no-member # reason: PySide binds the Qt signal at runtime + self._cases.itemSelectionChanged.connect(self._show_case) + self.retranslate() + + def menu_actions(self) -> list[tuple[str, Callable[[], None]]]: + """Expose all operations through the owning tab's Actions menu.""" + return [("healing_eval_browse", self.browse), ("healing_eval_run", self.evaluate), + ("healing_eval_cancel", self.cancel), ("healing_eval_export_json", self.export_json), + ("healing_eval_export_html", self.export_html)] + + def retranslate(self) -> None: + """Translate headers without discarding results or the active request.""" + TranslatableMixin.retranslate(self) + self._summary.setHorizontalHeaderLabels([_t(key) for key in _SUMMARY]) + self._cases.setHorizontalHeaderLabels([_t(key) for key in _CASES]) + self._set_status(self._state, self._error) + self._render() + + def _set_status(self, key: str, detail: str = "") -> None: + self._state, self._error = key, detail + self._status.setText(_t(key) + (": " + detail if detail else "")) + + def browse(self) -> None: + """Choose a local manifest without running it.""" + path, _filter = QFileDialog.getOpenFileName(self, _t("browse"), "", "JSON (*.json)") + if path: + self._path.setText(path) + + def _path_changed(self, _text: str) -> None: + self._generation += 1 + self.cancel() + self._clear() + self._set_status("idle") + + def _clear(self) -> None: + self._report = None + self._summary.setRowCount(0) + self._cases.setRowCount(0) + self._details.clear() + + def evaluate(self) -> None: + """Start one background comparison after authorizing the owning caller.""" + if self._handle is not None: + self._set_status("busy") + return + try: + authorize_command("AC_self_heal_evaluate") + except AutoControlException as error: + self._set_status("error", str(error)) + return + self._generation += 1 + self._clear() + worker = HealingEvaluationWorker(self._generation, self._path.text().strip()) + self._worker = worker + # pylint: disable-next=no-member # reason: PySide binds the Qt signal at runtime + self.destroyed.connect(worker.request_stop) + self._set_status("running") + self._handle = start_worker(self, worker, on_done=self._completed, + on_thread_done=self._thread_done, on_fail=self._failed) + + @Slot() + def cancel(self) -> None: + """Request stop without blocking the GUI or publishing partial results.""" + if self._worker is not None: + self._worker.request_stop() + + def _thread_done(self) -> None: + self._handle = None + self._worker = None + + def _failed(self, error: str) -> None: + self._set_status("error", error) + + @Slot(object) + def _completed(self, outcome: HealingEvaluationOutcome) -> None: + if outcome.request_id != self._generation: + return + self._set_status(outcome.status, outcome.error) + if outcome.report is not None: + self._report = outcome.report + self._render() + + def _render(self) -> None: + if self._report is None: + return + _populate(self._summary, [_version_row(name, version) for name, version in self._report.versions.items()]) + _populate(self._cases, [_case_row(row) for row in self._report.attempts]) + + def _show_case(self) -> None: + if self._report is None or self._cases.currentRow() < 0: + return + attempt = self._report.attempts[self._cases.currentRow()] + self._details.setPlainText(json.dumps(asdict(attempt), ensure_ascii=False, indent=2)) + + def export_json(self) -> None: + """Export the shared JSON snapshot chosen through a save dialog.""" + self._export("json") + + def export_html(self) -> None: + """Export a standalone comparison with escaped case details.""" + self._export("html") + + def _export(self, suffix: str) -> None: + if self._report is None: + return + path, _filter = QFileDialog.getSaveFileName(self, _t("export_" + suffix), + f"locator-comparison.{suffix}", f"*.{suffix}") + if path: + try: + authorize_command("AC_self_heal_evaluate") + write_healing_report(self._report, path) + except (AutoControlException, OSError) as error: + self._set_status("error", str(error)) + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 # reason: Qt override name + """Cancel cooperatively while worker ownership protects a closing panel.""" + self.cancel() + super().closeEvent(event) diff --git a/je_auto_control/gui/healing_evaluation_worker.py b/je_auto_control/gui/healing_evaluation_worker.py new file mode 100644 index 000000000..fe9d2a508 --- /dev/null +++ b/je_auto_control/gui/healing_evaluation_worker.py @@ -0,0 +1,68 @@ +"""Cancellable fixed-frame comparisons retaining their registrant's context and authority.""" +from asyncio import CancelledError as AsyncCancelledError +from concurrent.futures import CancelledError +from contextvars import copy_context +from dataclasses import dataclass +from threading import Event +from typing import Literal, Optional + +from PySide6.QtCore import QObject, Signal, Slot + +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.logging.secret_scope import scrub_secret_text +from je_auto_control.utils.rbac.deferred import DeferredAuthorization +from je_auto_control.utils.run_history.run_outcome import _ordinary_error_text, is_run_interruption +from je_auto_control.utils.self_healing.evaluation import HealingComparison +from je_auto_control.utils.self_healing.evaluation_io import evaluate_healing_manifest + + +@dataclass(frozen=True) +class HealingEvaluationOutcome: + """One terminal result; interrupted work never publishes a partial comparison.""" + + request_id: int + status: Literal["ok", "cancelled", "error"] + report: Optional[HealingComparison] = None + error: str = "" + + +class HealingEvaluationWorker(QObject): + """Perform local comparison off the GUI thread with cooperative checkpoints.""" + + finished = Signal(object) + + def __init__(self, request_id: int, path: str) -> None: + super().__init__() + self._request_id, self._path = request_id, path + self._stop = Event() + self._context = copy_context() + self._authority = DeferredAuthorization.capture() + + @Slot() + def request_stop(self) -> None: + """Request stop between file reads and comparison pairs without blocking Qt.""" + self._stop.set() + + def _checkpoint(self, _count: int) -> None: + if self._stop.is_set(): + raise CancelledError() + + @Slot() + def run(self) -> None: + """Emit a terminal outcome under the captured caller context.""" + self.finished.emit(self._context.run(self._evaluate)) + + def _evaluate(self) -> HealingEvaluationOutcome: + try: + self._checkpoint(0) + with self._authority.scope(): + report = evaluate_healing_manifest(self._path, observer=self._checkpoint) + self._checkpoint(0) + except (CancelledError, AsyncCancelledError): + return HealingEvaluationOutcome(self._request_id, "cancelled") + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: + if is_run_interruption(error): + return HealingEvaluationOutcome(self._request_id, "cancelled") + return HealingEvaluationOutcome(self._request_id, "error", + error=scrub_secret_text(_ordinary_error_text(error))) + return HealingEvaluationOutcome(self._request_id, "ok", report) diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 30e2d89a7..ebb0a7fc8 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -1367,4 +1367,33 @@ "journal_col_parent_id": "Parent ID", "manual_history_record": "Record in Run History", "manual_journal_optional": "Journal file (optional)", + "healing_eval_heading": "Version comparison", + "healing_eval_log": "Runtime log", + "healing_eval_path": "Comparison manifest", + "healing_eval_explanation": ("Fixed PNGs only. Recovery requires explicit operation verification; " + "unknown labels are excluded."), + "healing_eval_browse": "Choose comparison manifest", + "healing_eval_run": "Compare locator versions", + "healing_eval_cancel": "Cancel comparison", + "healing_eval_export_json": "Export comparison JSON", + "healing_eval_export_html": "Export comparison HTML", + "healing_eval_version": "Version", + "healing_eval_accuracy": "Correct / labelled", + "healing_eval_false_positive": "Wrong / predicted", + "healing_eval_recovery": "Verified recovery", + "healing_eval_unknown": "Unknown", + "healing_eval_p50": "p50 ms", + "healing_eval_p95": "p95 ms", + "healing_eval_sample": "Sample", + "healing_eval_correct": "Localization", + "healing_eval_run_id": "Run", + "healing_eval_step_id": "Step", + "healing_eval_busy": "A comparison is still finishing.", + "healing_eval_running": "Comparing fixed frames…", + "healing_eval_ok": "Comparison complete", + "healing_eval_cancelled": "Comparison cancelled", + "healing_eval_error": "Comparison failed", + "healing_eval_idle": "Choose a manifest, then use Actions to compare.", + "healing_eval_correct_true": "Correct", + "healing_eval_correct_false": "Incorrect", } diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index b5cacfc03..e589cddee 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -1254,4 +1254,32 @@ "journal_col_parent_id": "親ステップ ID", "manual_history_record": "実行履歴に記録", "manual_journal_optional": "ログファイル(省略可)", + "healing_eval_heading": "バージョン比較", + "healing_eval_log": "実行ログ", + "healing_eval_path": "比較マニフェスト", + "healing_eval_explanation": "固定 PNG のみ。復旧成功には操作後の検証が必要です。未ラベルは正解率から除外します。", + "healing_eval_browse": "比較マニフェストを選択", + "healing_eval_run": "ロケーターを比較", + "healing_eval_cancel": "比較をキャンセル", + "healing_eval_export_json": "比較 JSON を保存", + "healing_eval_export_html": "比較 HTML を保存", + "healing_eval_version": "バージョン", + "healing_eval_accuracy": "正解/ラベル済み", + "healing_eval_false_positive": "誤検出/検出済み", + "healing_eval_recovery": "検証済み復旧", + "healing_eval_unknown": "不明", + "healing_eval_p50": "p50 ミリ秒", + "healing_eval_p95": "p95 ミリ秒", + "healing_eval_sample": "サンプル", + "healing_eval_correct": "位置判定", + "healing_eval_run_id": "実行", + "healing_eval_step_id": "ステップ", + "healing_eval_busy": "比較処理が終了していません。", + "healing_eval_running": "固定画像を比較中…", + "healing_eval_ok": "比較完了", + "healing_eval_cancelled": "比較をキャンセルしました", + "healing_eval_error": "比較に失敗しました", + "healing_eval_idle": "マニフェストを選び、アクションから比較してください。", + "healing_eval_correct_true": "正解", + "healing_eval_correct_false": "不正解", } diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index cd23bfcb8..5151b397a 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -1233,4 +1233,32 @@ "journal_col_parent_id": "父步骤 ID", "manual_history_record": "记录到运行历史", "manual_journal_optional": "日志文件(可选)", + "healing_eval_heading": "版本比较", + "healing_eval_log": "运行记录", + "healing_eval_path": "比较数据集", + "healing_eval_explanation": "只读固定 PNG;恢复成功需明确的操作后验证,未标注样本不计入正确率。", + "healing_eval_browse": "选择比较数据集", + "healing_eval_run": "比较定位器版本", + "healing_eval_cancel": "取消比较", + "healing_eval_export_json": "导出比较 JSON", + "healing_eval_export_html": "导出比较 HTML", + "healing_eval_version": "版本", + "healing_eval_accuracy": "正确/已标注", + "healing_eval_false_positive": "误判/有坐标", + "healing_eval_recovery": "已验证恢复", + "healing_eval_unknown": "未知", + "healing_eval_p50": "p50 毫秒", + "healing_eval_p95": "p95 毫秒", + "healing_eval_sample": "样本", + "healing_eval_correct": "定位判断", + "healing_eval_run_id": "运行", + "healing_eval_step_id": "步骤", + "healing_eval_busy": "比较任务尚未结束。", + "healing_eval_running": "正在比较固定图像…", + "healing_eval_ok": "比较完成", + "healing_eval_cancelled": "比较已取消", + "healing_eval_error": "比较失败", + "healing_eval_idle": "选择数据集,再从“动作”执行比较。", + "healing_eval_correct_true": "正确", + "healing_eval_correct_false": "错误", } diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index d2d5c64ec..1772757bc 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -1234,4 +1234,32 @@ "journal_col_parent_id": "父步驟 ID", "manual_history_record": "記錄至執行歷史", "manual_journal_optional": "日誌檔案(選填)", + "healing_eval_heading": "版本比較", + "healing_eval_log": "執行紀錄", + "healing_eval_path": "比較資料集", + "healing_eval_explanation": "只讀固定 PNG;恢復成功需明確的操作後驗證,未標註樣本不計入正確率。", + "healing_eval_browse": "選擇比較資料集", + "healing_eval_run": "比較定位器版本", + "healing_eval_cancel": "取消比較", + "healing_eval_export_json": "匯出比較 JSON", + "healing_eval_export_html": "匯出比較 HTML", + "healing_eval_version": "版本", + "healing_eval_accuracy": "正確/已標註", + "healing_eval_false_positive": "誤判/有座標", + "healing_eval_recovery": "已驗證恢復", + "healing_eval_unknown": "未知", + "healing_eval_p50": "p50 毫秒", + "healing_eval_p95": "p95 毫秒", + "healing_eval_sample": "樣本", + "healing_eval_correct": "定位判定", + "healing_eval_run_id": "執行", + "healing_eval_step_id": "步驟", + "healing_eval_busy": "比較工作尚未結束。", + "healing_eval_running": "正在比較固定影像…", + "healing_eval_ok": "比較完成", + "healing_eval_cancelled": "比較已取消", + "healing_eval_error": "比較失敗", + "healing_eval_idle": "選擇資料集,再從「動作」執行比較。", + "healing_eval_correct_true": "正確", + "healing_eval_correct_false": "錯誤", } diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 2fb3f94f8..b2a4a20cd 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -71,6 +71,11 @@ def _build_specs() -> List[CommandSpec]: _add_window_specs(specs) _add_flow_specs(specs) _add_journal_specs(specs) + specs.append(CommandSpec( + "AC_self_heal_evaluate", "Image", "Compare Locator Versions", + fields=(FieldSpec("path", FieldType.FILE_PATH),), + description="Read a local fixed-frame comparison manifest; no capture, clicks or network backend.", + )) _add_misc_specs(specs) return specs diff --git a/je_auto_control/gui/self_healing_tab.py b/je_auto_control/gui/self_healing_tab.py index 66ed19d6a..7cecaa63f 100644 --- a/je_auto_control/gui/self_healing_tab.py +++ b/je_auto_control/gui/self_healing_tab.py @@ -1,7 +1,7 @@ -"""Self-Healing Locator tab. +"""Self-Healing runtime log and fixed-frame version comparison tab. Fire a template-first / VLM-fallback locate from the GUI and browse the -audit log of every healing attempt the runtime has performed. +audit log, or compare explicit versions on provided PNG samples without device effects. """ from typing import Optional @@ -9,10 +9,11 @@ from PySide6.QtWidgets import ( QCheckBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QLabel, QLineEdit, QMessageBox, - QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.healing_evaluation_panel import HealingEvaluationPanel from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( language_wrapper, ) @@ -58,7 +59,11 @@ def _build_layout(self) -> None: # menu; the tab keeps only the inputs, the log table, and the status. root = QVBoxLayout(self) root.addWidget(self._build_form_group()) - root.addWidget(self._table, stretch=1) + self._views = QTabWidget() + self._evaluation = HealingEvaluationPanel(self) + self._views.addTab(self._table, "") + self._views.addTab(self._evaluation, "") + root.addWidget(self._views, stretch=1) root.addWidget(self._status) self._apply_translations() self.refresh_log() @@ -81,11 +86,14 @@ def menu_actions(self) -> list: ("self_heal_click_btn", self._on_click), ("self_heal_refresh", self.refresh_log), ("self_heal_clear", self._on_clear_log), - ] + ] + self._evaluation.menu_actions() # --- translation ----------------------------------------------- def _apply_translations(self) -> None: + self._views.setTabText(0, _t("healing_eval_log")) + self._views.setTabText(1, _t("healing_eval_heading")) + self._evaluation.retranslate() self._group_box.setTitle(_t("self_heal_form_title")) self._template_input.setPlaceholderText(_t("self_heal_template_placeholder")) self._description_input.setPlaceholderText(_t("self_heal_desc_placeholder")) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 22b45cbd3..0f71f4178 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -38,6 +38,7 @@ from je_auto_control.utils.logging.secret_scope import secret_log_scope from je_auto_control.utils.run_history.run_outcome import raise_if_run_interrupted from je_auto_control.utils.run_history.commands import _history_capture_body +from je_auto_control.utils.self_healing.evaluation_io import _evaluate_manifest_command from je_auto_control.utils.executor.mouse_aliases import MOUSE_BUTTON_COMMANDS from je_auto_control.utils.llm.planner import ( plan_actions as llm_plan_actions, @@ -7820,6 +7821,7 @@ def __init__(self): # Self-healing locator (template-first, VLM fallback, audit log) "AC_self_heal_locate": _self_heal_locate, + "AC_self_heal_evaluate": _evaluate_manifest_command, "AC_self_heal_click": _self_heal_click, "AC_self_heal_log_list": _self_heal_log_list, "AC_self_heal_log_clear": _self_heal_log_clear, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 101d46baa..091ec35ec 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -6,6 +6,7 @@ MCP client surfaces to the model. """ from typing import List +from je_auto_control.utils.mcp_server.tools._healing_evaluation import healing_evaluation_tools from je_auto_control.utils.mcp_server.tools import _handlers as h from je_auto_control.utils.mcp_server.tools import _handlers_qa as hq @@ -9037,7 +9038,7 @@ def media_assert_tools() -> List[MCPTool]: ALL_FACTORIES = ( - journal_tools, + journal_tools, healing_evaluation_tools, mouse_tools, keyboard_tools, screen_tools, image_and_ocr_tools, window_tools, system_tools, recording_tools, drag_and_send_tools, semantic_locator_tools, self_healing_tools, anchor_locator_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_healing_evaluation.py b/je_auto_control/utils/mcp_server/tools/_healing_evaluation.py new file mode 100644 index 000000000..6ee2dd0fd --- /dev/null +++ b/je_auto_control/utils/mcp_server/tools/_healing_evaluation.py @@ -0,0 +1,40 @@ +"""Read-only local locator comparisons with bounded file access and cancellation.""" +from typing import Optional + +from je_auto_control.utils.mcp_server.context import ToolCallContext +from je_auto_control.utils.mcp_server.tools._base import MCPTool, READ_ONLY, schema + + +def _evaluate(path: str, *, ctx: Optional[ToolCallContext] = None) -> dict[str, object]: + # pylint: disable-next=import-outside-toplevel # reason: defer evaluator business imports until tool execution + from je_auto_control.utils.self_healing.evaluation_io import evaluate_healing_manifest + + previous = -1 + + def progress(count: int) -> None: + nonlocal previous + if ctx is not None: + ctx.check_cancelled() + if count != previous: + ctx.progress(count, message="Locator comparisons completed") + previous = count + ctx.check_cancelled() + + result = evaluate_healing_manifest(path, path_policy=ctx.path_policy if ctx is not None else None, + observer=progress) + return result.to_dict() + + +def healing_evaluation_tools() -> list[MCPTool]: + """Expose the same experimental report as API, executor and GUI.""" + return [MCPTool( + name="ac_self_heal_evaluate", + description=("Compare local image locator versions on fixed PNG samples from a schema-v1 manifest. " + "Returns labelled accuracy, false positives, verified recovery and latency populations. " + "Never captures, clicks or selects a network backend. Private file reads need drive_input."), + input_schema=schema({"path": {"type": "string", "format": "path"}}, required=["path"]), + handler=_evaluate, annotations=READ_ONLY, + output_schema=schema({"schema_version": {"type": "integer"}, "cost_unit": {"type": "string"}, + "versions": {"type": "object"}, "attempts": {"type": "array"}}, + required=["schema_version", "cost_unit", "versions", "attempts"]), + )] diff --git a/je_auto_control/utils/rbac/policy.py b/je_auto_control/utils/rbac/policy.py index bcaca8d65..1f9cb44b2 100644 --- a/je_auto_control/utils/rbac/policy.py +++ b/je_auto_control/utils/rbac/policy.py @@ -73,6 +73,7 @@ #: covered by the capability of the surface that submitted the action list. COMMAND_CAPABILITIES: Dict[str, str] = { "AC_history_capture": _DRIVE, + "AC_self_heal_evaluate": _DRIVE, "AC_journal_capture": _DRIVE, "AC_journal_read": _DRIVE, "AC_sign_action_file": Capability.SIGN_ACTIONS, @@ -189,6 +190,7 @@ "ac_repair_pending": _DRIVE, "ac_vlm_locate": _DRIVE, "ac_self_heal_locate": _DRIVE, + "ac_self_heal_evaluate": _DRIVE, "ac_describe_screen": _DRIVE, } diff --git a/je_auto_control/utils/self_healing/__init__.py b/je_auto_control/utils/self_healing/__init__.py index d699cae08..fd9698c86 100644 --- a/je_auto_control/utils/self_healing/__init__.py +++ b/je_auto_control/utils/self_healing/__init__.py @@ -8,6 +8,8 @@ * :class:`HealOutcome` — structured result both helpers return; * :data:`default_heal_log` — singleton JSON-lines log every heal attempt is appended to (override per-call via ``log=`` argument). +* :func:`evaluate_locators` — compare explicit strategies on fixed PNG samples; + :func:`evaluate_healing_manifest` and :func:`write_healing_report` share local reports. """ from je_auto_control.utils.self_healing.heal_log import ( HealEvent, HealEventLog, default_heal_log, @@ -17,10 +19,20 @@ METHOD_IMAGE, METHOD_MISS, METHOD_VLM, self_heal_click, self_heal_locate, ) +from je_auto_control.utils.self_healing.evaluation import HealingComparison, evaluate_locators +from je_auto_control.utils.self_healing.evaluation_models import ( + EvaluationRatio, EvaluationSample, HealingAttempt, HealingEvaluationError, + HealingVersionReport, LocatorCandidate, LocatorStrategy, +) +from je_auto_control.utils.self_healing.frame_locators import TemplateFrameLocator, VLMFrameLocator +from je_auto_control.utils.self_healing.evaluation_io import evaluate_healing_manifest, write_healing_report __all__ = [ "HealEvent", "HealEventLog", "HealOutcome", "SelfHealError", "METHOD_IMAGE", "METHOD_MISS", "METHOD_VLM", "default_heal_log", "self_heal_click", "self_heal_locate", + "EvaluationRatio", "EvaluationSample", "HealingAttempt", "HealingComparison", "HealingEvaluationError", + "HealingVersionReport", "LocatorCandidate", "LocatorStrategy", "TemplateFrameLocator", "VLMFrameLocator", + "evaluate_healing_manifest", "evaluate_locators", "write_healing_report", ] diff --git a/je_auto_control/utils/self_healing/evaluation.py b/je_auto_control/utils/self_healing/evaluation.py new file mode 100644 index 000000000..cff710836 --- /dev/null +++ b/je_auto_control/utils/self_healing/evaluation.py @@ -0,0 +1,175 @@ +"""Compare locator versions on identical provided frames with explicit quality populations.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from hashlib import sha256 +from math import floor +from time import perf_counter +from types import MappingProxyType +from typing import Callable, Mapping, Optional, Sequence + +from je_auto_control.utils.logging.secret_scope import scrub_secret_data, scrub_secret_text +from je_auto_control.utils.self_healing.evaluation_models import ( + EvaluationRatio, EvaluationSample, HealingAttempt, HealingEvaluationError, + HealingVersionReport, LocatorCandidate, LocatorStrategy, +) + + +@dataclass(frozen=True) +class HealingComparison: + """A comparison snapshot; JSON output is sanitized and contains no frame payloads.""" + + versions: Mapping[str, HealingVersionReport] + attempts: tuple[HealingAttempt, ...] + schema_version: int = 1 + + def __post_init__(self) -> None: + object.__setattr__(self, "versions", MappingProxyType(dict(self.versions))) + + def to_dict(self) -> dict[str, object]: + """Expose ratios, provenance and failures without dumping image bytes.""" + versions = {} + for name, version in self.versions.items(): + values = asdict(version) + for field_name in ("accuracy", "false_positive", "recovery", "image_hit", "fallback", "miss"): + values[field_name] = getattr(version, field_name).to_dict() + versions[name] = values + result = {"schema_version": self.schema_version, "cost_unit": "USD", "versions": versions, + "attempts": [asdict(attempt) for attempt in self.attempts]} + sanitized = scrub_secret_data(result) + if not isinstance(sanitized, dict): + raise HealingEvaluationError("comparison serialization must be an object") + return sanitized + + +def evaluate_locators(samples: Sequence[EvaluationSample], versions: Mapping[str, LocatorStrategy], *, + observer: Optional[Callable[[int], None]] = None) -> HealingComparison: + """Measure each version on the same immutable sample; never capture or operate a device. + + Wrong points and unexpected detections count as false positives among labelled + predictions. Recovery requires both a correct located point and an explicit + post-operation verification. Unknown labels/verification are excluded from the + respective denominators. Latency percentiles use linear interpolation. + Strategy exceptions (including cancellation) propagate, never count as misses. + """ + bound_samples, bound_versions = tuple(samples), tuple(versions.items()) + _validate_comparison(bound_samples, bound_versions) + attempts: list[HealingAttempt] = [] + if observer is not None: + observer(0) + for sample in bound_samples: + digest = sha256(sample.frame).hexdigest() + for version, strategy in bound_versions: + if observer is not None: + observer(len(attempts)) + started = perf_counter() + candidate = strategy.locate(sample) + elapsed = (perf_counter() - started) * 1000 + if not isinstance(candidate, LocatorCandidate): + raise HealingEvaluationError("strategy must return LocatorCandidate") + attempts.append(_attempt(sample, version, digest, candidate, elapsed)) + if observer is not None: + observer(len(attempts)) + reports = {scrub_secret_text(name): _summarize([row for row in attempts if row.version == scrub_secret_text(name)]) + for name, _strategy in bound_versions} + return HealingComparison(reports, tuple(attempts)) + + +def _validate_comparison(samples: tuple[EvaluationSample, ...], + versions: tuple[tuple[str, LocatorStrategy], ...]) -> None: + if not versions or len(versions) > 32 or len(samples) * len(versions) > 10000: + raise HealingEvaluationError("comparison requires 1–32 versions and at most 10000 pairs") + if any(not isinstance(sample, EvaluationSample) for sample in samples): + raise HealingEvaluationError("samples must be EvaluationSample values") + if len({scrub_secret_text(sample.sample_id) for sample in samples}) != len(samples): + raise HealingEvaluationError("sample IDs must be unique") + _validate_versions(versions) + + +def _validate_versions(versions: tuple[tuple[str, LocatorStrategy], ...]) -> None: + for version, strategy in versions: + if not isinstance(version, str) or not version.strip() or not callable(getattr(strategy, "locate", None)): + raise HealingEvaluationError("versions require nonempty names and LocatorStrategy values") + names = [scrub_secret_text(name) for name, _ in versions] + if len(set(names)) != len(names): + raise HealingEvaluationError("version names must stay distinct after secret masking") + + +def _attempt(sample: EvaluationSample, version: str, digest: str, + candidate: LocatorCandidate, elapsed: float) -> HealingAttempt: + correct = _correct(sample, candidate) + clean_candidate = LocatorCandidate( + candidate.coordinates, candidate.method, candidate.fallback_used, candidate.verified, + scrub_secret_text(candidate.backend) if candidate.backend is not None else None, + scrub_secret_text(candidate.model) if candidate.model is not None else None, + candidate.cost, tuple((scrub_secret_text(name), duration) for name, duration in candidate.timings_ms)) + return HealingAttempt(scrub_secret_text(sample.sample_id), scrub_secret_text(version), digest, clean_candidate, + correct, correct is False and candidate.coordinates is not None, elapsed, + scrub_secret_text(sample.run_id) if sample.run_id is not None else None, + scrub_secret_text(sample.step_id) if sample.step_id is not None else None, + sample.expected_box, sample.expected_miss, sample.origin, sample.scale, sample.region) + + +def _correct(sample: EvaluationSample, candidate: LocatorCandidate) -> Optional[bool]: + if sample.expected_miss: + return candidate.coordinates is None + if sample.expected_box is None: + return None + if candidate.coordinates is None: + return False + if sample.region is not None and not _inside(candidate.coordinates, sample.region): + return False + left, top, right, bottom = sample.expected_box + x, y = candidate.coordinates + return left <= x < right and top <= y < bottom + + +def _inside(point: tuple[float, float], box: tuple[float, float, float, float]) -> bool: + return box[0] <= point[0] < box[2] and box[1] <= point[1] < box[3] + + +def _summarize(rows: list[HealingAttempt]) -> HealingVersionReport: + quality = _quality(rows) + activity = _activity(rows) + costs = [row.candidate.cost for row in rows if row.candidate.cost is not None] + durations = sorted(row.duration_ms for row in rows) + return HealingVersionReport( + len(rows), quality[0], quality[1], quality[2], quality[3], + activity[0], activity[1], activity[2], quality[4], + _percentile(durations, 0.5), _percentile(durations, 0.95), sum(costs) if costs else None, len(costs)) + + +def _quality(rows: list[HealingAttempt]) -> tuple[int, EvaluationRatio, EvaluationRatio, EvaluationRatio, int]: + labelled = [row for row in rows if row.correct is not None] + predicted = [row for row in labelled if row.candidate.coordinates is not None] + recovery, unknown = _recovery(rows) + return ( + len(rows) - len(labelled), + EvaluationRatio(sum(row.correct is True for row in labelled), len(labelled)), + EvaluationRatio(sum(row.false_positive for row in predicted), len(predicted)), + recovery, unknown) + + +def _recovery(rows: list[HealingAttempt]) -> tuple[EvaluationRatio, int]: + fallback = [row for row in rows if row.candidate.fallback_used] + verified = [row for row in fallback if row.correct is not None and row.candidate.verified is not None] + return EvaluationRatio(sum(_recovered(row) for row in verified), len(verified)), len(fallback) - len(verified) + + +def _recovered(row: HealingAttempt) -> bool: + return row.correct is True and row.candidate.coordinates is not None and row.candidate.verified is True + + +def _activity(rows: list[HealingAttempt]) -> tuple[EvaluationRatio, EvaluationRatio, EvaluationRatio]: + return ( + EvaluationRatio(sum(row.candidate.method == "image" for row in rows), len(rows)), + EvaluationRatio(sum(row.candidate.fallback_used for row in rows), len(rows)), + EvaluationRatio(sum(row.candidate.coordinates is None for row in rows), len(rows))) + + +def _percentile(values: list[float], fraction: float) -> Optional[float]: + if not values: + return None + position = (len(values) - 1) * fraction + lower = floor(position) + return values[lower] + (values[min(lower + 1, len(values) - 1)] - values[lower]) * (position - lower) diff --git a/je_auto_control/utils/self_healing/evaluation_io.py b/je_auto_control/utils/self_healing/evaluation_io.py new file mode 100644 index 000000000..95078da1c --- /dev/null +++ b/je_auto_control/utils/self_healing/evaluation_io.py @@ -0,0 +1,162 @@ +"""Bounded local comparison manifests and shared JSON/HTML report export.""" +from __future__ import annotations + +import html +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Optional + +from je_auto_control.utils.json_store.json_store import atomic_write_text +from je_auto_control.utils.logging.secret_scope import scrub_secret_text +from je_auto_control.utils.path_guard.path_guard import validate_path +from je_auto_control.utils.path_guard.policy import PathPolicy +from je_auto_control.utils.self_healing.evaluation import HealingComparison, evaluate_locators +from je_auto_control.utils.self_healing.evaluation_models import EvaluationSample, HealingEvaluationError, checked_box +from je_auto_control.utils.self_healing.frame_locators import TemplateFrameLocator + + +def _bounded_path(path: str | Path, policy: Optional[PathPolicy], *, parent: Optional[Path] = None) -> Path: + candidate = validate_path(path, allowed_roots=[parent] if parent is not None else None) + # pylint: disable-next=import-outside-toplevel # reason: capture imports facade-backed executor adapters + from je_auto_control.utils.action_journal.capture import _effective_path_policies + for bound in _effective_path_policies(policy): + candidate = bound.validate(candidate, operation="self-healing evaluation") + return candidate + + +def _read(path: Path, limit: int) -> bytes: + with path.open("rb") as stream: + payload = stream.read(limit + 1) + if len(payload) > limit: + raise HealingEvaluationError("evaluation input exceeds its size limit") + return payload + + +def _object(value: object, allowed: set[str], required: set[str]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) - allowed or required - set(value): + raise HealingEvaluationError("manifest object has missing or unsupported fields") + return value + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for name, value in pairs: + if name in result: + raise HealingEvaluationError("manifest JSON keys must be unique") + result[name] = value + return result + + +@dataclass +class _AssetReader: + parent: Path + policy: Optional[PathPolicy] + remaining: int = 64 * 1024 * 1024 + + def read(self, value: object) -> bytes: + """Read one confined asset while reducing the shared encoded-data budget.""" + if not isinstance(value, str) or not value.strip() or Path(value).is_absolute(): + raise HealingEvaluationError("assets must be relative file paths") + path = _bounded_path(self.parent / value, self.policy, parent=self.parent) + data = _read(path, min(self.remaining, 16 * 1024 * 1024)) + self.remaining -= len(data) + return data + + +def _sample(value: object, reader: _AssetReader) -> EvaluationSample: + fields = {"id", "frame", "expected_box", "expected_miss", "origin", "scale", "region", "run_id", "step_id"} + row = _object(value, fields, {"id", "frame"}) + expected = checked_box(row["expected_box"], "expected_box") if row.get("expected_box") is not None else None + region = checked_box(row["region"], "region") if row.get("region") is not None else None + return EvaluationSample(row["id"], reader.read(row["frame"]), expected, + row.get("origin", (0, 0)), row.get("scale", 1), row.get("expected_miss", False), + region, row.get("run_id"), row.get("step_id")) + + +def _version(value: object, reader: _AssetReader) -> TemplateFrameLocator: + row = _object(value, {"template", "threshold"}, {"template"}) + return TemplateFrameLocator(reader.read(row["template"]), row.get("threshold", 0.9)) + + +def evaluate_healing_manifest(path: str | Path, *, path_policy: Optional[PathPolicy] = None, + observer: Optional[Callable[[int], None]] = None) -> HealingComparison: + """Compare only local image versions; no implicit capture, input or network backend. + + Schema-v1 paths are relative to the manifest directory and cannot escape it, + including through symlinks. Explicit and enclosing journal policies intersect. + PNGs are bounded to 16 MiB each, manifest to 1 MiB, total pixels/files to + 64 MiB of encoded data. Observer exceptions propagate before further work. + """ + if observer is not None: + observer(0) + candidate = _bounded_path(path, path_policy) + try: + payload = json.loads(_read(candidate, 1024 * 1024), object_pairs_hook=_unique_object) + except (ValueError, UnicodeError) as error: + raise HealingEvaluationError("manifest must be UTF-8 JSON") from error + body = _object(payload, {"schema_version", "samples", "versions"}, {"schema_version", "samples", "versions"}) + _check_manifest(body) + samples, versions = [], {} + reader = _AssetReader(candidate.parent, path_policy) + for row in body["samples"]: + if observer is not None: + observer(0) + samples.append(_sample(row, reader)) + for name, row in body["versions"].items(): + if observer is not None: + observer(0) + versions[name] = _version(row, reader) + return evaluate_locators(samples, versions, observer=observer) + + +def _check_manifest(body: dict[str, Any]) -> None: + version = body["schema_version"] + if isinstance(version, bool) or not isinstance(version, int) or version != 1: + raise HealingEvaluationError("unsupported evaluation schema_version") + samples, versions = body["samples"], body["versions"] + if not isinstance(samples, list) or not 1 <= len(samples) <= 500: + raise HealingEvaluationError("manifest requires 1–500 samples") + if not isinstance(versions, dict) or not 1 <= len(versions) <= 32 or len(samples) * len(versions) > 10000: + raise HealingEvaluationError("manifest requires 1–32 versions and at most 10000 pairs") + + +def write_healing_report(comparison: HealingComparison, path: str | Path, *, + path_policy: Optional[PathPolicy] = None) -> Path: + """Atomically export sanitized JSON or a standalone escaped HTML comparison. + + Existing reports may be replaced. Source PNGs and templates are never written. + """ + destination = _bounded_path(path, path_policy) + if destination.suffix.lower() not in (".json", ".html"): + raise HealingEvaluationError("report path must end in .json or .html") + data = comparison.to_dict() + rendered = json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False) + if destination.suffix.lower() == ".html": + rendered = _html_report(comparison, rendered) + atomic_write_text(destination, rendered) + return destination + + +def _html_report(comparison: HealingComparison, rendered: str) -> str: + rows = [] + for name, version in comparison.versions.items(): + ratio = version.accuracy + latency = "unknown" if version.p95_ms is None else f"{version.p95_ms:.3f}" + cells = (scrub_secret_text(name), f"{ratio.numerator}/{ratio.denominator}", str(version.unknown), latency) + rows.append("" + "".join(f"{html.escape(cell)}" for cell in cells) + "") + return ("Locator comparison" + "" + "

Locator comparison

Localization accuracy excludes unknown labels. " + "Recovery requires a correct point and explicit operation verification. " + "False positives are wrong detections among labelled predictions. Latencies are measured wall time.

" + "" + "" + + "".join(rows) + "
VersionCorrect/labelledUnknownp95 ms

Cases, provenance and populations

"
+            + html.escape(rendered) + "
") + + +def _evaluate_manifest_command(path: str) -> dict[str, object]: + """Executor adapter shared by scripts, CLI and server action execution.""" + return evaluate_healing_manifest(path).to_dict() diff --git a/je_auto_control/utils/self_healing/evaluation_models.py b/je_auto_control/utils/self_healing/evaluation_models.py new file mode 100644 index 000000000..086025d9e --- /dev/null +++ b/je_auto_control/utils/self_healing/evaluation_models.py @@ -0,0 +1,235 @@ +"""Typed immutable inputs and measured results for fixed-frame locator evaluation.""" +from __future__ import annotations + +import io +import math +from dataclasses import dataclass, field +from typing import Literal, Optional, Protocol + +from je_auto_control.utils.exception.exceptions import AutoControlException + +Point = tuple[float, float] +Box = tuple[float, float, float, float] +Method = Literal["image", "vlm", "miss"] + + +class HealingEvaluationError(AutoControlException, ValueError): + """A comparison input, strategy result or dataset is invalid.""" + + +def finite_numbers(values: object, length: int, label: str) -> tuple[float, ...]: + """Validate a fixed-length finite numeric sequence without coercing strings/bools.""" + if not isinstance(values, (list, tuple)) or len(values) != length: + raise HealingEvaluationError(f"{label} requires {length} finite numbers") + if any(not _finite(value) for value in values): + raise HealingEvaluationError(f"{label} requires {length} finite numbers") + return tuple(float(value) for value in values) + + +def _finite(value: object) -> bool: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + try: + return math.isfinite(value) + except OverflowError: + return False + + +def checked_box(value: object, label: str) -> Box: + """Validate a positive-area half-open screen-space bounding box.""" + left, top, right, bottom = finite_numbers(value, 4, label) + if right <= left or bottom <= top: + raise HealingEvaluationError(f"{label} must have positive area") + return left, top, right, bottom + + +def png_size(frame: bytes) -> tuple[int, int]: + """Validate bounded PNG input without capturing a device or retaining a decoder.""" + if not isinstance(frame, bytes) or not frame or len(frame) > 16 * 1024 * 1024: + raise HealingEvaluationError("frame must be immutable PNG bytes, at most 16 MiB") + # pylint: disable-next=import-outside-toplevel # reason: importing the facade does not initialize image decoders + from PIL import Image, UnidentifiedImageError + try: + with Image.open(io.BytesIO(frame)) as image: + if image.format != "PNG" or image.width * image.height > 16_000_000: + raise HealingEvaluationError("frame must be a PNG of at most 16 million pixels") + image.verify() + return image.size + except (OSError, UnidentifiedImageError, Image.DecompressionBombError, SyntaxError) as error: + raise HealingEvaluationError("cannot decode evaluation PNG") from error + + +@dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes # reason: immutable dataset fields and explicit provenance +class EvaluationSample: + """One PNG; origin/boxes are logical screen units, scale is pixels per unit. + + No box and expected_miss=False means unknown, not an expected miss. + Regions must lie inside the supplied frame; every version sees this same input. + """ + + sample_id: str + frame: bytes = field(repr=False) + expected_box: Optional[Box] = None + origin: Point = (0.0, 0.0) + scale: float = 1.0 + expected_miss: bool = False + region: Optional[Box] = None + run_id: Optional[str] = None + step_id: Optional[str] = None + + def __post_init__(self) -> None: + if not isinstance(self.sample_id, str) or not self.sample_id.strip(): + raise HealingEvaluationError("sample_id must be a nonempty string") + width, height = png_size(self.frame) + origin = finite_numbers(self.origin, 2, "origin") + object.__setattr__(self, "origin", (origin[0], origin[1])) + self._check_scale() + self._check_labels() + self._check_region(width, height) + + def _check_scale(self) -> None: + if not _finite(self.scale) or self.scale <= 0: + raise HealingEvaluationError("scale must be a positive finite number") + if not isinstance(self.expected_miss, bool): + raise HealingEvaluationError("expected_miss must be boolean") + + def _check_region(self, width: int, height: int) -> None: + if self.region is not None: + region = checked_box(self.region, "region") + ox, oy = self.origin + if region[0] < ox or region[1] < oy or region[2] > ox + width / self.scale \ + or region[3] > oy + height / self.scale: + raise HealingEvaluationError("region lies outside supplied frame") + object.__setattr__(self, "region", region) + + def _check_labels(self) -> None: + if self.expected_box is not None: + if self.expected_miss: + raise HealingEvaluationError("expected_box and expected_miss are mutually exclusive") + object.__setattr__(self, "expected_box", checked_box(self.expected_box, "expected_box")) + for value in (self.run_id, self.step_id): + if value is not None and not isinstance(value, str): + raise HealingEvaluationError("run_id/step_id must be strings or null") + + +@dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes # reason: serializable candidate metadata, not mutable services +class LocatorCandidate: + """A point, not operation success; verification and optional USD cost are supplied separately.""" + + coordinates: Optional[Point] + method: Method + fallback_used: bool = False + verified: Optional[bool] = None + backend: Optional[str] = None + model: Optional[str] = None + cost: Optional[float] = None + timings_ms: tuple[tuple[str, float], ...] = () + + def __post_init__(self) -> None: + if self.method not in ("image", "vlm", "miss") or (self.method == "miss") != (self.coordinates is None): + raise HealingEvaluationError("candidate method must agree with coordinates") + if self.coordinates is not None: + point = finite_numbers(self.coordinates, 2, "coordinates") + object.__setattr__(self, "coordinates", (point[0], point[1])) + self._check_metadata() + self._check_timings() + + def _check_metadata(self) -> None: + if not isinstance(self.fallback_used, bool) \ + or self.verified is not None and not isinstance(self.verified, bool): + raise HealingEvaluationError("fallback_used/verified must be boolean (verified may be null)") + if self.method == "vlm" and not self.fallback_used: + raise HealingEvaluationError("a VLM candidate must record fallback_used") + for value in (self.backend, self.model): + if value is not None and not isinstance(value, str): + raise HealingEvaluationError("backend/model must be strings or null") + if self.cost is not None: + _nonnegative(self.cost, "cost") + + def _check_timings(self) -> None: + if not isinstance(self.timings_ms, (list, tuple)): + raise HealingEvaluationError("timings_ms requires name/duration pairs") + timings = tuple(self.timings_ms) + for pair in timings: + if not isinstance(pair, (list, tuple)) or len(pair) != 2: + raise HealingEvaluationError("timings_ms requires name/duration pairs") + name, duration = pair + if not isinstance(name, str) or not name: + raise HealingEvaluationError("timing names must be nonempty strings") + _nonnegative(duration, "timing") + if len({name for name, _ in timings}) != len(timings): + raise HealingEvaluationError("timing names must be unique") + object.__setattr__(self, "timings_ms", tuple((name, float(duration)) for name, duration in timings)) + + +def _nonnegative(value: float, name: str) -> None: + if not _finite(value) or value < 0: + raise HealingEvaluationError(f"{name} must be a finite nonnegative number") + + +# pylint: disable-next=too-few-public-methods # reason: one operation defines the supplied-frame strategy protocol +class LocatorStrategy(Protocol): + """A strategy consumes supplied pixels; evaluation itself never captures or clicks.""" + + def locate(self, sample: EvaluationSample) -> LocatorCandidate: + """Locate against this exact frame and region, returning logical screen coordinates.""" + + +@dataclass(frozen=True) +class EvaluationRatio: + """Explicit numerator/denominator; no eligible samples gives a null value.""" + + numerator: int + denominator: int + + @property + def value(self) -> Optional[float]: + """The measured fraction, or unknown when denominator is zero.""" + return self.numerator / self.denominator if self.denominator else None + + def to_dict(self) -> dict[str, object]: + """Return a JSON-safe ratio with its population visible.""" + return {"numerator": self.numerator, "denominator": self.denominator, "value": self.value} + + +@dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes # reason: immutable case evidence including coordinates and provenance +class HealingAttempt: + """One version/sample pair, with provenance but no image bytes.""" + + sample_id: str + version: str + frame_hash: str + candidate: LocatorCandidate + correct: Optional[bool] + false_positive: bool + duration_ms: float + run_id: Optional[str] = None + step_id: Optional[str] = None + expected_box: Optional[Box] = None + expected_miss: bool = False + origin: Point = (0.0, 0.0) + scale: float = 1.0 + region: Optional[Box] = None + + +@dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes # reason: independent measured populations in one report value +class HealingVersionReport: + """Counts and explicitly eligible populations for one locator version.""" + + total: int + unknown: int + accuracy: EvaluationRatio + false_positive: EvaluationRatio + recovery: EvaluationRatio + image_hit: EvaluationRatio + fallback: EvaluationRatio + miss: EvaluationRatio + verification_unknown: int + p50_ms: Optional[float] + p95_ms: Optional[float] + cost_total: Optional[float] + cost_known: int diff --git a/je_auto_control/utils/self_healing/frame_locators.py b/je_auto_control/utils/self_healing/frame_locators.py new file mode 100644 index 000000000..d0867cec4 --- /dev/null +++ b/je_auto_control/utils/self_healing/frame_locators.py @@ -0,0 +1,141 @@ +"""Image and explicitly supplied VLM strategies over provided PNGs, without screen capture.""" +from __future__ import annotations + +import io +from dataclasses import dataclass, replace +from math import ceil, floor +from time import perf_counter +from typing import Any, Optional + +from je_auto_control.utils.cv2_utils.optional import require_cv2 +from je_auto_control.utils.cv2_utils.template_detection import _to_gray +from je_auto_control.utils.self_healing.evaluation_models import ( + EvaluationSample, HealingEvaluationError, LocatorCandidate, LocatorStrategy, Point, finite_numbers, png_size, +) +from je_auto_control.utils.vision.backends.base import VLMBackend, VLMNotAvailableError + + +@dataclass(frozen=True) +class _FrameView: + png: bytes + origin: Point + scale: float + size: tuple[int, int] + + def screen_point(self, point: Point) -> Point: + """Validate local pixels and floor the transformed logical pointer coordinate.""" + x, y = finite_numbers(point, 2, "backend coordinates") + if not 0 <= x < self.size[0] or not 0 <= y < self.size[1]: + raise HealingEvaluationError("locator returned a point outside supplied search pixels") + return floor(self.origin[0] + x / self.scale), floor(self.origin[1] + y / self.scale) + + +def _view(sample: EvaluationSample) -> _FrameView: + size = png_size(sample.frame) + if sample.region is None: + return _FrameView(sample.frame, sample.origin, sample.scale, size) + left, top, right, bottom = sample.region + ox, oy = sample.origin + bounds = (ceil((left - ox) * sample.scale), ceil((top - oy) * sample.scale), + floor((right - ox) * sample.scale), floor((bottom - oy) * sample.scale)) + if bounds[2] <= bounds[0] or bounds[3] <= bounds[1]: + raise HealingEvaluationError("region contains no complete pixels") + # pylint: disable-next=import-outside-toplevel # reason: initialize optional decoders only when supplied pixels are used + from PIL import Image + stream = io.BytesIO() + with Image.open(io.BytesIO(sample.frame)) as image: + with image.crop(bounds) as cropped: + cropped.save(stream, format="PNG") + origin = ox + bounds[0] / sample.scale, oy + bounds[1] / sample.scale + return _FrameView(stream.getvalue(), origin, sample.scale, + (bounds[2] - bounds[0], bounds[3] - bounds[1])) + + +def _gray(png: bytes) -> Any: + # pylint: disable-next=import-outside-toplevel # reason: keep decoder work out of facade initialization + from PIL import Image + with Image.open(io.BytesIO(png)) as image: + # Own the array rather than referencing a decoder closed on return. + return _to_gray(require_cv2(), image).copy() + + +@dataclass(frozen=True) +class TemplateFrameLocator: + """Normalized template matching; an optional fallback gets the identical sample. + + Template pixels are supplied explicitly. Constant templates are refused because + normalized correlation is undefined and can otherwise match every location. + Thresholds use the runtime matcher's float32 epsilon at 1.0. + """ + + template: bytes + threshold: float = 0.9 + fallback: Optional[LocatorStrategy] = None + + def __post_init__(self) -> None: + png_size(self.template) + if isinstance(self.threshold, bool) or not isinstance(self.threshold, (int, float)) \ + or not 0 <= self.threshold <= 1: + raise HealingEvaluationError("threshold must be a number between 0 and 1") + + def locate(self, sample: EvaluationSample) -> LocatorCandidate: + """Match only supplied search pixels, then optionally run the explicit fallback.""" + started = perf_counter() + point = self._match(_view(sample)) + elapsed = (perf_counter() - started) * 1000 + if point is not None: + return LocatorCandidate(point, "image", timings_ms=(("image", elapsed),)) + if self.fallback is None: + return LocatorCandidate(None, "miss", timings_ms=(("image", elapsed),)) + result = self.fallback.locate(sample) + timings = (("image", elapsed), *((f"fallback.{name}", value) for name, value in result.timings_ms)) + return replace(result, fallback_used=True, timings_ms=timings) + + def _match(self, view: _FrameView) -> Optional[Point]: + cv2 = require_cv2() + frame, template = _gray(view.png), _gray(self.template) + if float(template.std()) == 0: + raise HealingEvaluationError("constant template cannot use normalized correlation") + height, width = template.shape[:2] + if height > frame.shape[0] or width > frame.shape[1]: + return None + # pylint: disable-next=no-member # reason: optional OpenCV exports these C extension symbols at runtime + scores = cv2.matchTemplate(frame, template, cv2.TM_CCOEFF_NORMED) + # pylint: disable-next=no-member # reason: optional OpenCV exports this C extension symbol at runtime + _low, score, _low_at, (x, y) = cv2.minMaxLoc(scores) + if score < min(self.threshold, 1.0 - 1e-5): + return None + return view.screen_point((x + width // 2, y + height // 2)) + + +@dataclass(frozen=True) +class VLMFrameLocator: + """Call an explicitly provided backend on supplied PNG bytes; never choose a paid backend implicitly. + + Backend replies use local image pixels. Origin and pixels-per-logical-unit scale + are applied once. A point from a model does not imply post-operation verification. + """ + + backend: VLMBackend + description: str + model: Optional[str] = None + + def __post_init__(self) -> None: + if not isinstance(self.description, str) or not self.description.strip(): + raise HealingEvaluationError("description must be nonempty") + if not isinstance(self.backend, VLMBackend): + raise HealingEvaluationError("backend must implement VLMBackend") + if self.model is not None and not isinstance(self.model, str): + raise HealingEvaluationError("model must be a string or null") + + def locate(self, sample: EvaluationSample) -> LocatorCandidate: + """Use the same cropped pixels as the image strategy, without capture or verification.""" + if not self.backend.available: + raise VLMNotAvailableError("explicit evaluation backend is unavailable") + started = perf_counter() + view = _view(sample) + point = self.backend.locate(view.png, self.description, model=self.model) + coordinates = None if point is None else view.screen_point(point) + elapsed = (perf_counter() - started) * 1000 + return LocatorCandidate(coordinates, "vlm" if point is not None else "miss", fallback_used=True, + backend=self.backend.name, model=self.model, timings_ms=(("vlm", elapsed),)) diff --git a/test/unit_test/headless/test_self_healing_evaluation.py b/test/unit_test/headless/test_self_healing_evaluation.py new file mode 100644 index 000000000..864f1a7f8 --- /dev/null +++ b/test/unit_test/headless/test_self_healing_evaluation.py @@ -0,0 +1,254 @@ +"""Fixed-frame comparisons separate localization, verification and unknown labels.""" +import io +import json +from pathlib import Path + +import pytest +from PIL import Image + +from je_auto_control.utils.self_healing.evaluation import evaluate_locators +from je_auto_control.utils.self_healing.evaluation_models import EvaluationSample, LocatorCandidate +from je_auto_control.utils.self_healing.evaluation_models import HealingEvaluationError +from je_auto_control.utils.self_healing.evaluation_io import evaluate_healing_manifest, write_healing_report +from je_auto_control.utils.self_healing.frame_locators import TemplateFrameLocator, VLMFrameLocator +from je_auto_control.utils.vision.backends.base import VLMBackend + +DATASET = Path(__file__).resolve().parents[3] / "benchmarks/self_healing" + + +def png(size=(40, 30)): + stream = io.BytesIO() + Image.new("RGB", size, "white").save(stream, format="PNG") + return stream.getvalue() + + +class FixedLocator: + def __init__(self, candidate): + self.candidate = candidate + self.seen = [] + + def locate(self, sample): + self.seen.append(sample) + return self.candidate + + +def test_unlabelled_is_unknown(): + report = evaluate_locators([EvaluationSample("unknown", png())], { + "v1": FixedLocator(LocatorCandidate((4, 5), "image")), + }) + version = report.versions["v1"] + assert version.unknown == 1 + assert version.accuracy.numerator == version.accuracy.denominator == 0 + assert version.accuracy.value is None + assert report.attempts[0].correct is None + + +def test_false_positive_is_not_recovery(): + samples = [EvaluationSample("labelled", png(), expected_box=(0, 0, 10, 10))] + bad = LocatorCandidate((20, 20), "vlm", fallback_used=True, verified=True) + good = LocatorCandidate((4, 5), "vlm", fallback_used=True) + report = evaluate_locators(samples, {"bad": FixedLocator(bad), "unverified": FixedLocator(good)}) + assert report.versions["bad"].false_positive.numerator == 1 + assert report.versions["bad"].recovery.numerator == 0 + assert report.versions["bad"].recovery.denominator == 1 + assert report.versions["unverified"].accuracy.numerator == 1 + assert report.versions["unverified"].recovery.denominator == 0 + assert report.versions["unverified"].verification_unknown == 1 + + +def test_same_frame_versions_are_comparable(): + sample = EvaluationSample("shared", png(), expected_miss=True) + left = FixedLocator(LocatorCandidate(None, "miss")) + right = FixedLocator(LocatorCandidate((4, 5), "image")) + report = evaluate_locators([sample], {"old": left, "new": right}) + assert left.seen[0] is right.seen[0] is sample + assert report.attempts[0].frame_hash == report.attempts[1].frame_hash + assert report.versions["old"].accuracy.numerator == 1 + assert report.versions["new"].false_positive.numerator == 1 + + +def test_region_passed_to_both_strategies(): + sample = EvaluationSample("crop", png(), origin=(-100, -50), scale=2, + region=(-98, -48, -90, -40)) + image, vlm = FixedLocator(LocatorCandidate(None, "miss")), FixedLocator(LocatorCandidate(None, "miss")) + evaluate_locators([sample], {"image": image, "vlm": vlm}) + assert image.seen[0].region == vlm.seen[0].region == (-98, -48, -90, -40) + + +def test_real_template_dataset_and_versioned_acceptance(): + report = evaluate_healing_manifest(DATASET / "dataset.json") + thresholds = json.loads((DATASET / "acceptance.json").read_text(encoding="utf-8")) + for name, expected in thresholds.items(): + actual = report.versions[name] + assert actual.accuracy.numerator == expected["correct"] + assert actual.accuracy.denominator == expected["labelled"] + assert actual.unknown == expected["unknown"] + assert actual.false_positive.numerator == expected["false_positive"] + assert actual.recovery.value is None + attempt = next(row for row in report.attempts if row.version == "new" and row.sample_id.startswith("negative")) + assert attempt.candidate.coordinates == (-66, -29) + assert attempt.correct is True and attempt.step_id == "scaled-submit" + + +def test_image_miss_and_vlm_use_same_crop_and_transform_without_capture(monkeypatch): + frame = (DATASET / "fixtures/frame_new.png").read_bytes() + sample = EvaluationSample("crop", frame, expected_box=(-70, -32, -61.5, -25.5), + origin=(-80, -40), scale=2, region=(-72, -34, -60, -24)) + seen = [] + + class Backend(VLMBackend): + available, name = True, "controlled" + + def locate(self, image_bytes, description, model=None, image_mime="image/png"): + seen.append(image_bytes) + return 12, 10 + + def verify(self, *_args, **_kwargs): + pytest.fail("no implicit post-operation verification") + + def forbidden(*_args, **_kwargs): + pytest.fail("evaluation must not capture or click") + + monkeypatch.setattr("je_auto_control.utils.monitor_layout.logical_frame.grab_logical", forbidden) + monkeypatch.setattr("je_auto_control.wrapper.auto_control_mouse.click_mouse", forbidden) + vlm = VLMFrameLocator(Backend(), "submit", model="fake") + image = TemplateFrameLocator((DATASET / "fixtures/old.png").read_bytes(), .99, vlm) + result = image.locate(sample) + assert result.coordinates == (-66, -29) and result.method == "vlm" + assert result.verified is None and result.fallback_used + assert [name for name, _ in result.timings_ms] == ["image", "fallback.vlm"] + with Image.open(io.BytesIO(frame)) as original, Image.open(io.BytesIO(seen[0])) as actual: + assert actual.size == (24, 20) + assert actual.tobytes() == original.crop((16, 12, 40, 32)).tobytes() + + +def test_measured_percentiles_costs_and_verified_recovery(monkeypatch): + ticks = iter([0, 1, 2, 4]) + monkeypatch.setattr("je_auto_control.utils.self_healing.evaluation.perf_counter", lambda: next(ticks)) + locator = FixedLocator(LocatorCandidate((4, 5), "vlm", fallback_used=True, verified=True, cost=.01)) + samples = [EvaluationSample(str(index), png(), expected_box=(0, 0, 10, 10)) for index in range(2)] + version = evaluate_locators(samples, {"v1": locator}).versions["v1"] + assert version.p50_ms == 1500 and version.p95_ms == 1950 + assert version.cost_total == .02 and version.cost_known == 2 + assert version.recovery.to_dict() == {"numerator": 2, "denominator": 2, "value": 1} + + +@pytest.mark.parametrize("fields", [ + {"scale": 0}, {"scale": float("nan")}, {"scale": True}, {"origin": (True, 0)}, + {"region": (-1, 0, 20, 20)}, {"expected_box": (0, 0, 0, 10)}, + {"expected_box": (0, 0, 10, 10), "expected_miss": True}, {"expected_miss": "false"}, + {"frame": bytearray(png())}, {"run_id": 1}, +]) +def test_invalid_samples_fail_at_boundary(fields): + values = {"sample_id": "invalid", "frame": png(), **fields} + with pytest.raises(HealingEvaluationError): + EvaluationSample(**values) + + +def test_strategy_and_observer_errors_propagate_without_partial_report(): + seen = [] + signal = RuntimeError("strategy aborted") + + class Failure: + def locate(self, sample): + seen.append(sample.sample_id) + raise signal + + samples = [EvaluationSample("first", png()), EvaluationSample("second", png())] + with pytest.raises(RuntimeError) as caught: + evaluate_locators(samples, {"v1": Failure()}) + assert caught.value is signal and seen == ["first"] + with pytest.raises(RuntimeError) as caught: + evaluate_locators(samples, {"v1": Failure()}, observer=lambda _: (_ for _ in ()).throw(signal)) + assert caught.value is signal and seen == ["first"] + + +def test_exports_escape_html_and_contain_provenance_without_images(tmp_path): + sample = EvaluationSample("", png(), expected_miss=True, run_id="run", step_id="step") + report = evaluate_locators([sample], {"": FixedLocator(LocatorCandidate(None, "miss"))}) + json_path = write_healing_report(report, tmp_path / "report.json") + html_path = write_healing_report(report, tmp_path / "report.html") + payload = json.loads(json_path.read_text(encoding="utf-8")) + assert payload["attempts"][0]["run_id"] == "run" and "frame" not in payload["attempts"][0] + html = html_path.read_text(encoding="utf-8") + assert "