Skip to content

Commit 7f5fd42

Browse files
authored
Merge pull request #433 from Integration-Automation/feat/lock-session-batch
Add lock_session: lock the workstation and wait for unlock
2 parents 23329d5 + f01545f commit 7f5fd42

11 files changed

Lines changed: 554 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+
### Lock the Workstation + Wait for Unlock
6+
7+
Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst).
8+
9+
- **`lock_session` / `plan_lock_session` / `wait_for_unlock` / `wait_for_lock` / `classify_lock_transitions`** (`AC_lock_session`, `AC_plan_lock_session`, `AC_wait_for_unlock`, `AC_classify_lock_transitions`): `session_guard` could *detect* a locked session and raise; this adds *acting* on it. `lock_session` locks the workstation now (`LockWorkStation` on Windows, `loginctl lock-session` / `CGSession -suspend` elsewhere) through an injectable `driver`; `wait_for_unlock` / `wait_for_lock` poll `session_guard.is_session_locked` (reusing its real Windows `OpenInputDesktop` probe) until the state flips or a timeout, with injectable `clock` / `sleep` / `probe`; `plan_lock_session` is the pure per-OS planner and `classify_lock_transitions` reduces a lock-state sample stream to `{event, locked}` lock/unlock events. `wait_for_unlock` is the blocking companion to `ensure_interactive_session`. Fifth feature of the ROUND-15 cross-app OS lane. No `PySide6`.
10+
511
### Read and Control the System Volume
612

713
Set a known audio baseline before a run — mute, set 30%, or assert the level. Full reference: [`docs/source/Eng/doc/new_features/v206_features_doc.rst`](docs/source/Eng/doc/new_features/v206_features_doc.rst).
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
Lock the Workstation + Wait for Unlock
2+
======================================
3+
4+
:mod:`session_guard` answers "is the session locked right now?" and raises if it
5+
is. The missing half is *acting* on the lock state: lock the machine at the end
6+
of an unattended run, block until a human unlocks it before resuming, or reduce
7+
a stream of lock-state samples to lock / unlock events. ``lock_session`` adds
8+
that, behind injectable seams so the logic is testable without touching the OS.
9+
10+
* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on
11+
Windows, ``loginctl lock-session`` on Linux, ``CGSession -suspend`` on macOS)
12+
through an injectable ``driver``.
13+
* :func:`plan_lock_session` — pure planner: how the lock would be performed on
14+
this OS and whether a default is available (``{backend, argv, available}``).
15+
* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll
16+
:func:`is_session_locked` until the state flips or a timeout, with injectable
17+
``clock`` / ``sleep`` / ``probe`` for deterministic tests.
18+
* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a
19+
list of ``{event, locked}`` lock / unlock transitions.
20+
21+
The lock probe reused by the wait helpers is :mod:`session_guard`'s — the
22+
Windows ``OpenInputDesktop`` check — so ``wait_for_unlock`` is the blocking
23+
companion to ``ensure_interactive_session`` (which only raises). Imports no
24+
``PySide6``.
25+
26+
Headless API
27+
------------
28+
29+
.. code-block:: python
30+
31+
from je_auto_control import (
32+
lock_session, wait_for_unlock, classify_lock_transitions,
33+
)
34+
35+
# ... unattended run finishes ...
36+
lock_session() # secure the machine
37+
38+
# Resume only once a human has unlocked the box
39+
if wait_for_unlock(timeout_s=600):
40+
run_next_stage()
41+
42+
# Reduce a sampled lock-state log to events
43+
classify_lock_transitions([False, True, True, False])
44+
# -> [{'event': 'lock', 'locked': True},
45+
# {'event': 'unlock', 'locked': False}]
46+
47+
For tests (or any host) pass a ``driver`` / ``probe``:
48+
49+
.. code-block:: python
50+
51+
locked = lock_session(driver=lambda: True) # no real lock
52+
wait_for_unlock(probe=lambda: False) # already unlocked
53+
54+
Executor commands
55+
-----------------
56+
57+
``AC_lock_session`` (→ ``{locked}``), ``AC_plan_lock_session`` (→ the plan),
58+
``AC_wait_for_unlock`` (``timeout`` / ``interval`` → ``{unlocked}``) and
59+
``AC_classify_lock_transitions`` (``states`` JSON list → ``{events}``). They are
60+
exposed as the matching ``ac_*`` MCP tools (``ac_lock_session`` is destructive —
61+
it interrupts the session; the rest are read-only) and as Script Builder
62+
commands under **Shell**.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
鎖定工作站 + 等待解鎖
2+
====================
3+
4+
:mod:`session_guard` 回答「目前 session 是否鎖定?」並在鎖定時丟出例外。缺少的另一半是對鎖定狀態
5+
*採取行動*:在無人值守執行結束時鎖定機器、在恢復前阻塞直到有人解鎖,或把一連串鎖定狀態取樣化約為
6+
鎖定 / 解鎖事件。``lock_session`` 補上這些,並以可注入接縫實作,故邏輯能在不碰作業系統的情況下測試。
7+
8+
* :func:`lock_session` ——立即鎖定工作站(Windows 用 ``LockWorkStation``、Linux 用
9+
``loginctl lock-session``、macOS 用 ``CGSession -suspend``),透過可注入的 ``driver``。
10+
* :func:`plan_lock_session` ——純 planner:此 OS 上會如何執行鎖定,以及是否有預設可用
11+
(``{backend, argv, available}``)。
12+
* :func:`wait_for_unlock` / :func:`wait_for_lock` ——輪詢 :func:`is_session_locked`
13+
直到狀態翻轉或逾時,``clock`` / ``sleep`` / ``probe`` 皆可注入以利確定性測試。
14+
* :func:`classify_lock_transitions` ——純函式:把一連串鎖定狀態取樣化約為
15+
``{event, locked}`` 鎖定 / 解鎖轉變的清單。
16+
17+
wait 系列重用的鎖定 probe 即 :mod:`session_guard` 的——Windows 的 ``OpenInputDesktop`` 檢查——
18+
故 ``wait_for_unlock`` 是 ``ensure_interactive_session``(只會丟例外)的阻塞式搭檔。不匯入 ``PySide6``。
19+
20+
無頭 API
21+
--------
22+
23+
.. code-block:: python
24+
25+
from je_auto_control import (
26+
lock_session, wait_for_unlock, classify_lock_transitions,
27+
)
28+
29+
# ... 無人值守執行結束 ...
30+
lock_session() # 鎖住機器
31+
32+
# 等有人解鎖後才繼續
33+
if wait_for_unlock(timeout_s=600):
34+
run_next_stage()
35+
36+
# 把取樣的鎖定狀態紀錄化約為事件
37+
classify_lock_transitions([False, True, True, False])
38+
# -> [{'event': 'lock', 'locked': True},
39+
# {'event': 'unlock', 'locked': False}]
40+
41+
測試時(或任何主機)可傳入 ``driver`` / ``probe``:
42+
43+
.. code-block:: python
44+
45+
locked = lock_session(driver=lambda: True) # 不真正鎖定
46+
wait_for_unlock(probe=lambda: False) # 已解鎖
47+
48+
執行器指令
49+
----------
50+
51+
``AC_lock_session``(→ ``{locked}``)、``AC_plan_lock_session``(→ 計畫)、
52+
``AC_wait_for_unlock``(``timeout`` / ``interval`` → ``{unlocked}``)與
53+
``AC_classify_lock_transitions``(``states`` JSON 清單 → ``{events}``)。皆以對應的 ``ac_*``
54+
MCP 工具(``ac_lock_session`` 為破壞性——會中斷 session;其餘為唯讀)及 Script Builder 指令
55+
(位於 **Shell** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@
105105
change_volume, get_volume, is_muted, mute, set_mute, set_volume,
106106
toggle_mute, unmute,
107107
)
108+
# Lock the workstation, wait for unlock, classify lock transitions
109+
from je_auto_control.utils.lock_session import (
110+
classify_lock_transitions, lock_session, plan_lock_session,
111+
wait_for_lock, wait_for_unlock,
112+
)
108113
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
109114
from je_auto_control.utils.clipboard_rich_formats import (
110115
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1720,6 +1725,8 @@ def start_autocontrol_gui(*args, **kwargs):
17201725
"normalize_ext", "file_association",
17211726
"get_volume", "set_volume", "change_volume",
17221727
"is_muted", "set_mute", "mute", "unmute", "toggle_mute",
1728+
"lock_session", "plan_lock_session", "wait_for_unlock",
1729+
"wait_for_lock", "classify_lock_transitions",
17231730
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17241731
"set_clipboard_rtf", "get_clipboard_rtf",
17251732
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4345,6 +4345,34 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
43454345
fields=(),
43464346
description="Flip the master mute flag.",
43474347
))
4348+
specs.append(CommandSpec(
4349+
"AC_lock_session", "Shell", "Lock Workstation",
4350+
fields=(),
4351+
description="Lock the workstation now (interrupts the session).",
4352+
))
4353+
specs.append(CommandSpec(
4354+
"AC_plan_lock_session", "Shell", "Plan Lock Workstation",
4355+
fields=(),
4356+
description="Describe how the workstation would be locked (pure).",
4357+
))
4358+
specs.append(CommandSpec(
4359+
"AC_wait_for_unlock", "Shell", "Wait for Unlock",
4360+
fields=(
4361+
FieldSpec("timeout", FieldType.FLOAT, optional=True, default=30.0,
4362+
placeholder="timeout seconds"),
4363+
FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.5,
4364+
placeholder="poll interval seconds"),
4365+
),
4366+
description="Block until the session is unlocked or timeout.",
4367+
))
4368+
specs.append(CommandSpec(
4369+
"AC_classify_lock_transitions", "Shell", "Classify Lock Transitions",
4370+
fields=(
4371+
FieldSpec("states", FieldType.STRING,
4372+
placeholder="JSON list of booleans"),
4373+
),
4374+
description="Reduce lock-state samples to lock / unlock events.",
4375+
))
43484376
specs.append(CommandSpec(
43494377
"AC_normalize_ext", "Shell", "Normalize Extension",
43504378
fields=(

je_auto_control/utils/executor/action_executor.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2683,6 +2683,34 @@ def _toggle_mute() -> Dict[str, Any]:
26832683
return {"muted": bool(toggle_mute())}
26842684

26852685

2686+
def _lock_session() -> Dict[str, Any]:
2687+
"""Adapter: lock the workstation now."""
2688+
from je_auto_control.utils.lock_session import lock_session
2689+
return {"locked": bool(lock_session())}
2690+
2691+
2692+
def _plan_lock_session() -> Dict[str, Any]:
2693+
"""Adapter: describe how the workstation would be locked (pure)."""
2694+
from je_auto_control.utils.lock_session import plan_lock_session
2695+
return plan_lock_session()
2696+
2697+
2698+
def _wait_for_unlock(timeout: Any = 30.0, interval: Any = 0.5
2699+
) -> Dict[str, Any]:
2700+
"""Adapter: block until the session is unlocked or timeout."""
2701+
from je_auto_control.utils.lock_session import wait_for_unlock
2702+
unlocked = wait_for_unlock(timeout_s=float(timeout),
2703+
interval_s=float(interval))
2704+
return {"unlocked": bool(unlocked)}
2705+
2706+
2707+
def _classify_lock_transitions(states: Any) -> Dict[str, Any]:
2708+
"""Adapter: reduce lock-state samples to lock / unlock events (pure)."""
2709+
from je_auto_control.utils.lock_session import classify_lock_transitions
2710+
samples = [bool(s) for s in _coerce_list(states)] if states else []
2711+
return {"events": classify_lock_transitions(samples)}
2712+
2713+
26862714
def _normalize_ext(target: str) -> Dict[str, Any]:
26872715
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
26882716
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6697,6 +6725,10 @@ def __init__(self):
66976725
"AC_change_volume": _change_volume,
66986726
"AC_set_mute": _set_mute,
66996727
"AC_toggle_mute": _toggle_mute,
6728+
"AC_lock_session": _lock_session,
6729+
"AC_plan_lock_session": _plan_lock_session,
6730+
"AC_wait_for_unlock": _wait_for_unlock,
6731+
"AC_classify_lock_transitions": _classify_lock_transitions,
67006732
"AC_normalize_ext": _normalize_ext,
67016733
"AC_file_association": _file_association,
67026734
"AC_get_control_text": _get_control_text,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Lock the workstation, wait for unlock, and classify lock transitions."""
2+
from je_auto_control.utils.lock_session.lock_session import (
3+
classify_lock_transitions, lock_session, plan_lock_session,
4+
wait_for_lock, wait_for_unlock,
5+
)
6+
7+
__all__ = [
8+
"lock_session", "plan_lock_session", "wait_for_unlock", "wait_for_lock",
9+
"classify_lock_transitions",
10+
]
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Lock the workstation, wait for it to be unlocked, and classify transitions.
2+
3+
:mod:`session_guard` answers "is the session locked right now?" and raises if it
4+
is. The missing half is *acting* on the lock state: lock the machine at the end
5+
of an unattended run, block until a human unlocks it before resuming, or reduce
6+
a stream of lock-state samples to lock / unlock events. ``lock_session`` adds:
7+
8+
* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on
9+
Windows, ``loginctl lock-session`` / ``CGSession -suspend`` elsewhere),
10+
through an injectable ``driver`` seam.
11+
* :func:`plan_lock_session` — pure planner describing how the lock would be
12+
performed on this OS, and whether a default is available.
13+
* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll
14+
:func:`session_guard.is_session_locked` until the state flips or a timeout,
15+
with injectable ``clock`` / ``sleep`` / ``probe`` for deterministic tests.
16+
* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a
17+
list of ``{event, locked}`` lock / unlock transitions.
18+
19+
Imports no ``PySide6``.
20+
"""
21+
import sys
22+
import time
23+
from typing import Any, Callable, Dict, List, Optional, Sequence
24+
25+
from je_auto_control.utils.session_guard import is_session_locked
26+
from je_auto_control.utils.session_guard.session_guard import LockProbe
27+
28+
# A driver performs the lock and returns whether it succeeded.
29+
LockDriver = Callable[[], bool]
30+
31+
32+
def _win_lock() -> bool:
33+
"""Lock the Windows workstation via ``LockWorkStation``."""
34+
import ctypes
35+
user32 = ctypes.windll.user32 # nosec B607 # reason: fixed system DLL
36+
return bool(user32.LockWorkStation())
37+
38+
39+
def _lock_backend() -> str:
40+
"""Return the lock backend name for the current platform."""
41+
if sys.platform.startswith("win"):
42+
return "LockWorkStation"
43+
if sys.platform == "darwin":
44+
return "CGSession"
45+
return "loginctl"
46+
47+
48+
_CGSESSION = ("/System/Library/CoreServices/Menu Extras/"
49+
"User.menu/Contents/Resources/CGSession")
50+
51+
52+
def _lock_argv(backend: str) -> Optional[List[str]]:
53+
"""Return the fixed argv for a subprocess lock backend, or None."""
54+
if backend == "loginctl":
55+
return ["loginctl", "lock-session"]
56+
if backend == "CGSession":
57+
return [_CGSESSION, "-suspend"]
58+
return None
59+
60+
61+
def plan_lock_session() -> Dict[str, Any]:
62+
"""Describe how the workstation would be locked on this OS (pure).
63+
64+
Returns ``{backend, argv, available}``. ``available`` is ``True`` when this
65+
platform has a built-in default; otherwise :func:`lock_session` needs an
66+
explicit ``driver=``.
67+
"""
68+
backend = _lock_backend()
69+
argv = _lock_argv(backend)
70+
available = backend == "LockWorkStation" or argv is not None
71+
return {"backend": backend, "argv": argv, "available": available}
72+
73+
74+
def _run_argv(argv: List[str]) -> bool:
75+
"""Run a fixed lock argv and report success (no shell)."""
76+
import subprocess # nosec B404 # reason: fixed argv, no shell
77+
completed = subprocess.run(argv, check=False) # nosec B603 # nosemgrep
78+
return completed.returncode == 0
79+
80+
81+
def _default_driver() -> LockDriver:
82+
"""Return the OS lock driver, or raise if none is available."""
83+
backend = _lock_backend()
84+
if backend == "LockWorkStation":
85+
return _win_lock
86+
argv = _lock_argv(backend)
87+
if argv is not None:
88+
return lambda: _run_argv(argv)
89+
raise RuntimeError(
90+
"lock_session has no OS driver on this platform; pass driver=")
91+
92+
93+
def lock_session(*, driver: Optional[LockDriver] = None) -> bool:
94+
"""Lock the workstation now; return whether the lock was requested.
95+
96+
Pass ``driver`` (a ``() -> bool``) to intercept the OS call in tests; the
97+
default locks via the platform backend from :func:`plan_lock_session`.
98+
"""
99+
acquire = driver if driver is not None else _default_driver()
100+
return bool(acquire())
101+
102+
103+
def _wait_lock_state(target_locked: bool, *, probe: Optional[LockProbe],
104+
timeout_s: float, interval_s: float,
105+
clock: Callable[[], float],
106+
sleep: Callable[[float], None]) -> bool:
107+
"""Poll until the lock state equals ``target_locked`` or timeout."""
108+
deadline = clock() + float(timeout_s)
109+
while True:
110+
if bool(is_session_locked(probe)) == target_locked:
111+
return True
112+
if clock() >= deadline:
113+
return False
114+
sleep(float(interval_s))
115+
116+
117+
def wait_for_unlock(*, probe: Optional[LockProbe] = None,
118+
timeout_s: float = 30.0, interval_s: float = 0.5,
119+
clock: Callable[[], float] = time.monotonic,
120+
sleep: Callable[[float], None] = time.sleep) -> bool:
121+
"""Block until the session is unlocked; return ``True``, or ``False`` on timeout.
122+
123+
Reuses :func:`session_guard.is_session_locked` (Windows default probe).
124+
``clock`` / ``sleep`` / ``probe`` are injectable for deterministic tests.
125+
"""
126+
return _wait_lock_state(False, probe=probe, timeout_s=timeout_s,
127+
interval_s=interval_s, clock=clock, sleep=sleep)
128+
129+
130+
def wait_for_lock(*, probe: Optional[LockProbe] = None,
131+
timeout_s: float = 30.0, interval_s: float = 0.5,
132+
clock: Callable[[], float] = time.monotonic,
133+
sleep: Callable[[float], None] = time.sleep) -> bool:
134+
"""Block until the session is locked; return ``True``, or ``False`` on timeout."""
135+
return _wait_lock_state(True, probe=probe, timeout_s=timeout_s,
136+
interval_s=interval_s, clock=clock, sleep=sleep)
137+
138+
139+
def classify_lock_transitions(states: Sequence[bool]) -> List[Dict[str, Any]]:
140+
"""Reduce lock-state samples to lock / unlock transitions (pure).
141+
142+
Each adjacent ``False -> True`` yields a ``lock`` event and each
143+
``True -> False`` an ``unlock`` event; unchanged samples yield nothing.
144+
"""
145+
events: List[Dict[str, Any]] = []
146+
previous: Optional[bool] = None
147+
for sample in states:
148+
current = bool(sample)
149+
if previous is not None and current != previous:
150+
kind = "lock" if current else "unlock"
151+
events.append({"event": kind, "locked": current})
152+
previous = current
153+
return events

0 commit comments

Comments
 (0)