Skip to content

Commit 5ca1c5f

Browse files
committed
Add ensure_state: idempotent read-compare-act-verify for control state
Acting unconditionally double-toggles an already-checked box or re-enters an already-correct field and can't be safely re-run. ensure_state reads first and only acts (then re-reads to verify) when the state differs; ensure_toggle is the boolean flip specialization. A control already in the desired state is left untouched, so the call is idempotent. Distinct from idempotency (request-key cache) — this converges device state.
1 parent f269e43 commit 5ca1c5f

11 files changed

Lines changed: 348 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## What's new (2026-06-26)
44

5+
### Ensure a Control Is in the Desired State (Idempotent)
6+
7+
Read-compare-act-verify instead of acting blind — don't double-toggle an already-checked box. Full reference: [`docs/source/Eng/doc/new_features/v212_features_doc.rst`](docs/source/Eng/doc/new_features/v212_features_doc.rst).
8+
9+
- **`ensure_state` / `ensure_toggle`** (`AC_ensure_field_value`): automation that acts *unconditionally* double-toggles a box that was already checked or re-enters an already-correct field, and can't be safely re-run. The robust shape is read-compare-act-verify. `ensure_state` reads via an injectable `reader`, and only if it doesn't equal `desired` applies `setter` and re-reads (up to `attempts`); `ensure_toggle` is the boolean specialization that calls `toggle` only while the state differs. A control already in the desired state is left untouched (`changed=False`) — idempotent and safe to re-run, distinct from `idempotency` (a request-key replay cache) since this converges *device state*. The executor's `AC_ensure_field_value` idempotently sets a native control's value via the accessibility backend. Fourth feature of the ROUND-15 input-fidelity lane. No `PySide6`.
10+
511
### Adaptive Timeout from Observed Durations
612

713
Stop guessing wait timeouts — learn them from how long the step actually takes. Full reference: [`docs/source/Eng/doc/new_features/v211_features_doc.rst`](docs/source/Eng/doc/new_features/v211_features_doc.rst).
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
Ensure a Control Is in the Desired State (Idempotent)
2+
=====================================================
3+
4+
Automation that *acts unconditionally* — "click the checkbox", "type the value"
5+
— double-toggles a box that was already checked, or re-enters a field that was
6+
already correct, and can't be safely re-run. The robust shape is
7+
read-compare-act-verify: look at the current state, do nothing if it already
8+
matches, otherwise apply the change and confirm it took. ``ensure_state`` is
9+
that primitive.
10+
11+
* :func:`ensure_state` — generic: read via ``reader``, and if it doesn't equal
12+
``desired`` apply ``setter`` and re-read, up to ``attempts`` times.
13+
* :func:`ensure_toggle` — the boolean specialization for a stateless flip: read
14+
``is_on`` and call ``toggle`` only while it differs from ``desired``.
15+
16+
A control already in the desired state is left untouched (``changed=False``), so
17+
the call is idempotent and safe to re-run. This is distinct from
18+
:mod:`idempotency` (a request-key replay cache) — ``ensure_state`` converges
19+
*device state*, not call results. The reader / setter / toggle seams are
20+
injectable, so the logic is fully testable without a real control. Imports no
21+
``PySide6``.
22+
23+
Headless API
24+
------------
25+
26+
.. code-block:: python
27+
28+
from je_auto_control import ensure_state, ensure_toggle
29+
30+
# Idempotently make a setting "on" — no write if it already is
31+
ensure_state("on", reader=read_combo, setter=write_combo)
32+
# -> {'ok': True, 'changed': False, 'value': 'on', 'attempts': 0}
33+
34+
# Flip a checkbox to checked only if it isn't already
35+
ensure_toggle(True, is_on=is_checked, toggle=click_checkbox)
36+
37+
Both return ``{ok, changed, value, attempts}``: ``changed`` tells you whether an
38+
action was actually performed (useful for "did I have to fix this?" reporting),
39+
and ``ok`` whether the desired state was reached within ``attempts``. Pass a
40+
custom ``equals`` to :func:`ensure_state` for case-insensitive or normalized
41+
comparisons.
42+
43+
Executor commands
44+
-----------------
45+
46+
``AC_ensure_field_value`` (``desired`` + ``name`` / ``role`` / ``app_name`` /
47+
``automation_id`` / ``attempts`` → ``{ok, changed, value, attempts}``)
48+
idempotently sets a native control's value through the accessibility backend —
49+
reading first and doing nothing if it already matches. It is the matching
50+
``ac_ensure_field_value`` MCP tool and a Script Builder command under **Flow**.
51+
:func:`ensure_state` / :func:`ensure_toggle` (which take arbitrary callables) are
52+
the Python-API surface.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
確保控制項處於目標狀態(冪等)
2+
==============================
3+
4+
*無條件採取行動*的自動化——「點選核取方塊」、「輸入該值」——會把已勾選的方塊再次切換,或對已正確的
5+
欄位重新輸入,且無法安全地重跑。穩健的型態是讀取-比較-行動-驗證:看目前狀態,若已相符就什麼都不做,
6+
否則套用變更並確認生效。``ensure_state`` 正是此原語。
7+
8+
* :func:`ensure_state` ——通用:透過 ``reader`` 讀取,若不等於 ``desired`` 就套用 ``setter`` 並
9+
重讀,最多 ``attempts`` 次。
10+
* :func:`ensure_toggle` ——針對無狀態翻轉的布林特化:讀取 ``is_on``,僅在與 ``desired`` 不同時
11+
呼叫 ``toggle``。
12+
13+
已處於目標狀態的控制項會保持不動(``changed=False``),故此呼叫是冪等且可安全重跑。這有別於
14+
:mod:`idempotency`(請求鍵重放快取)——``ensure_state`` 收斂的是*裝置狀態*,而非呼叫結果。
15+
reader / setter / toggle 接縫皆可注入,故邏輯能在沒有真實控制項的情況下完整測試。不匯入 ``PySide6``。
16+
17+
無頭 API
18+
--------
19+
20+
.. code-block:: python
21+
22+
from je_auto_control import ensure_state, ensure_toggle
23+
24+
# 冪等地把某設定設為 "on" ——若已是則不寫入
25+
ensure_state("on", reader=read_combo, setter=write_combo)
26+
# -> {'ok': True, 'changed': False, 'value': 'on', 'attempts': 0}
27+
28+
# 僅在尚未勾選時把核取方塊翻為勾選
29+
ensure_toggle(True, is_on=is_checked, toggle=click_checkbox)
30+
31+
兩者皆回傳 ``{ok, changed, value, attempts}``:``changed`` 告訴你是否實際執行了動作
32+
(對「我是否得修正它?」的報告很有用),``ok`` 則是是否在 ``attempts`` 內達到目標狀態。
33+
:func:`ensure_state` 傳入自訂 ``equals`` 可做不分大小寫或正規化比較。
34+
35+
執行器指令
36+
----------
37+
38+
``AC_ensure_field_value``(``desired`` 加上 ``name`` / ``role`` / ``app_name`` /
39+
``automation_id`` / ``attempts`` → ``{ok, changed, value, attempts}``)透過無障礙後端
40+
冪等地設定原生控制項的值——先讀取,若已相符則不做任何事。以對應的 ``ac_ensure_field_value``
41+
MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。:func:`ensure_state` /
42+
:func:`ensure_toggle`(接受任意 callable)則是 Python API 介面。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@
127127
from je_auto_control.utils.adaptive_timeout import (
128128
recommend_timeout, timeout_stats,
129129
)
130+
# Idempotently bring a control / setting to a desired state
131+
from je_auto_control.utils.ensure_state import ensure_state, ensure_toggle
130132
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
131133
from je_auto_control.utils.clipboard_rich_formats import (
132134
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1749,6 +1751,7 @@ def start_autocontrol_gui(*args, **kwargs):
17491751
"RetryBudget", "run_with_budget", "backoff_delay", "jittered_delay",
17501752
"compare_field_value", "verify_field_value", "fill_and_verify",
17511753
"recommend_timeout", "timeout_stats",
1754+
"ensure_state", "ensure_toggle",
17521755
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17531756
"set_clipboard_rtf", "get_clipboard_rtf",
17541757
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4484,6 +4484,22 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
44844484
),
44854485
description="Timeout recommendation plus percentiles and clamp flags.",
44864486
))
4487+
specs.append(CommandSpec(
4488+
"AC_ensure_field_value", "Flow", "Ensure Field Value",
4489+
fields=(
4490+
FieldSpec("desired", FieldType.STRING, placeholder="desired value"),
4491+
FieldSpec("name", FieldType.STRING, optional=True,
4492+
placeholder="control name"),
4493+
FieldSpec("role", FieldType.STRING, optional=True,
4494+
placeholder="control role"),
4495+
FieldSpec("app_name", FieldType.STRING, optional=True,
4496+
placeholder="app name"),
4497+
FieldSpec("automation_id", FieldType.STRING, optional=True,
4498+
placeholder="automation id"),
4499+
FieldSpec("attempts", FieldType.INT, optional=True, default=2),
4500+
),
4501+
description="Idempotently set a control's value (no-op if already set).",
4502+
))
44874503
specs.append(CommandSpec(
44884504
"AC_normalize_ext", "Shell", "Normalize Extension",
44894505
fields=(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Idempotently bring a control or setting to a desired state."""
2+
from je_auto_control.utils.ensure_state.ensure_state import (
3+
ensure_state, ensure_toggle,
4+
)
5+
6+
__all__ = ["ensure_state", "ensure_toggle"]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Idempotently bring a control or setting to a desired state: read, act, verify.
2+
3+
Automation that *acts unconditionally* — "click the checkbox", "type the value"
4+
— double-toggles a box that was already checked, or re-enters a field that was
5+
already correct, and can't be safely re-run. The robust shape is
6+
read-compare-act-verify: look at the current state, do nothing if it already
7+
matches, otherwise apply the change and confirm it took. ``ensure_state`` is
8+
that primitive.
9+
10+
* :func:`ensure_state` — generic: read via ``reader``, and if it doesn't equal
11+
``desired`` apply ``setter`` and re-read, up to ``attempts`` times.
12+
* :func:`ensure_toggle` — the boolean specialization for a stateless flip: read
13+
``is_on`` and call ``toggle`` only while it differs from ``desired``.
14+
15+
A control already in the desired state is left untouched (``changed=False``),
16+
so the call is idempotent and safe to re-run. The reader / setter / toggle seams
17+
are injectable, so the logic is fully testable without a real control. Distinct
18+
from :mod:`idempotency` (a request-key replay cache) — this converges *device
19+
state*, not call results. Imports no ``PySide6``.
20+
"""
21+
from typing import Any, Callable, Dict
22+
23+
StateReader = Callable[[], Any]
24+
StateSetter = Callable[[Any], None]
25+
StateEquals = Callable[[Any, Any], bool]
26+
27+
28+
def _default_equals(left: Any, right: Any) -> bool:
29+
"""Default equality used to decide whether the state already matches."""
30+
return left == right
31+
32+
33+
def ensure_state(desired: Any, *, reader: StateReader, setter: StateSetter,
34+
equals: StateEquals = _default_equals,
35+
attempts: int = 2) -> Dict[str, Any]:
36+
"""Bring the state read by ``reader`` to ``desired`` via ``setter`` (idempotent).
37+
38+
Reads the current state; if ``equals(current, desired)`` it returns
39+
immediately with ``changed=False``. Otherwise it applies ``setter(desired)``
40+
and re-reads, up to ``attempts`` times. Returns
41+
``{ok, changed, value, attempts}``.
42+
"""
43+
current = reader()
44+
if equals(current, desired):
45+
return {"ok": True, "changed": False, "value": current, "attempts": 0}
46+
used = 0
47+
for used in range(1, max(1, int(attempts)) + 1):
48+
setter(desired)
49+
current = reader()
50+
if equals(current, desired):
51+
return {"ok": True, "changed": True, "value": current,
52+
"attempts": used}
53+
return {"ok": False, "changed": True, "value": current, "attempts": used}
54+
55+
56+
def ensure_toggle(desired: bool, *, is_on: Callable[[], bool],
57+
toggle: Callable[[], None],
58+
attempts: int = 2) -> Dict[str, Any]:
59+
"""Bring a boolean toggle to ``desired`` by flipping only while it differs.
60+
61+
``is_on`` reads the current boolean; ``toggle`` performs a stateless flip and
62+
is called only when the state does not already match ``desired``. Returns
63+
``{ok, changed, value, attempts}``.
64+
"""
65+
return ensure_state(bool(desired), reader=lambda: bool(is_on()),
66+
setter=lambda _desired: toggle(), attempts=attempts)

je_auto_control/utils/executor/action_executor.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2805,6 +2805,24 @@ def _timeout_stats(durations: Any, percentile_q: Any = 95.0, factor: Any = 1.5,
28052805
max_s=float(max_s))
28062806

28072807

2808+
def _ensure_field_value(desired: Any, name: Optional[str] = None,
2809+
role: Optional[str] = None,
2810+
app_name: Optional[str] = None,
2811+
automation_id: Optional[str] = None,
2812+
attempts: Any = 2) -> Dict[str, Any]:
2813+
"""Adapter: idempotently set a native control's value and verify (read-act)."""
2814+
from je_auto_control.utils.ensure_state import ensure_state
2815+
return ensure_state(
2816+
str(desired),
2817+
reader=lambda: _control_get_value(name=name, role=role,
2818+
app_name=app_name,
2819+
automation_id=automation_id),
2820+
setter=lambda value: _control_set_value(value, name=name, role=role,
2821+
app_name=app_name,
2822+
automation_id=automation_id),
2823+
attempts=int(attempts))
2824+
2825+
28082826
def _normalize_ext(target: str) -> Dict[str, Any]:
28092827
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
28102828
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6833,6 +6851,7 @@ def __init__(self):
68336851
"AC_verify_field_value": _verify_field_value,
68346852
"AC_adaptive_timeout": _adaptive_timeout,
68356853
"AC_timeout_stats": _timeout_stats,
6854+
"AC_ensure_field_value": _ensure_field_value,
68366855
"AC_normalize_ext": _normalize_ext,
68376856
"AC_file_association": _file_association,
68386857
"AC_get_control_text": _get_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1857,6 +1857,23 @@ def smart_wait_tools() -> List[MCPTool]:
18571857
handler=h.timeout_stats,
18581858
annotations=READ_ONLY,
18591859
),
1860+
MCPTool(
1861+
name="ac_ensure_field_value",
1862+
description=("Idempotently set a native control's value to "
1863+
"'desired' and verify: reads first and does nothing if "
1864+
"it already matches. Identify the control by name / "
1865+
"role / app_name / automation_id. Returns {ok, "
1866+
"changed, value, attempts}."),
1867+
input_schema=schema({"desired": {"type": "string"},
1868+
"name": {"type": "string"},
1869+
"role": {"type": "string"},
1870+
"app_name": {"type": "string"},
1871+
"automation_id": {"type": "string"},
1872+
"attempts": {"type": "integer"}},
1873+
required=["desired"]),
1874+
handler=h.ensure_field_value,
1875+
annotations=SIDE_EFFECT_ONLY,
1876+
),
18601877
]
18611878

18621879

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,15 @@ def timeout_stats(durations, percentile_q=95.0, factor=1.5, min_s=1.0,
712712
return _timeout_stats(durations, percentile_q, factor, min_s, max_s)
713713

714714

715+
def ensure_field_value(desired, name=None, role=None, app_name=None,
716+
automation_id=None, attempts=2):
717+
from je_auto_control.utils.executor.action_executor import (
718+
_ensure_field_value,
719+
)
720+
return _ensure_field_value(desired, name, role, app_name, automation_id,
721+
attempts)
722+
723+
715724
def normalize_ext(target):
716725
from je_auto_control.utils.executor.action_executor import _normalize_ext
717726
return _normalize_ext(target)

0 commit comments

Comments
 (0)