chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6
tools/ui_inspector/evidence/__init__.py
Normal file
6
tools/ui_inspector/evidence/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Asynchronous evidence capture."""
|
||||
|
||||
from .coordinator import EvidenceCoordinator
|
||||
|
||||
__all__ = ["EvidenceCoordinator"]
|
||||
|
||||
255
tools/ui_inspector/evidence/coordinator.py
Normal file
255
tools/ui_inspector/evidence/coordinator.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Coalesced background full-page evidence capture.
|
||||
|
||||
Semantic actions are committed immediately. Slow dumps are supplementary
|
||||
evidence and are explicitly labelled eventual instead of being misrepresented
|
||||
as an event-time after-state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvidenceRequest:
|
||||
action_id: str
|
||||
context: dict[str, Any]
|
||||
requested_monotonic: float
|
||||
|
||||
|
||||
class EvidenceCoordinator:
|
||||
def __init__(
|
||||
self,
|
||||
manager: Any,
|
||||
dump_fn: Callable[..., Any],
|
||||
*,
|
||||
coalesce_sec: float = 0.25,
|
||||
on_dump: Optional[Callable[[Any], None]] = None,
|
||||
) -> None:
|
||||
self.manager = manager
|
||||
self.dump_fn = dump_fn
|
||||
self.coalesce_sec = coalesce_sec
|
||||
self.on_dump = on_dump
|
||||
self._queue: queue.Queue[EvidenceRequest | None] = queue.Queue(maxsize=512)
|
||||
self._urgent_queue: queue.Queue[EvidenceRequest | None] = queue.Queue(maxsize=128)
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._urgent_thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self.dropped = 0
|
||||
self.capture_count = 0
|
||||
self.failure_count = 0
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="ui-inspector-evidence", daemon=True)
|
||||
self._thread.start()
|
||||
if str(getattr(self.manager.action_flow, "surface", "")) == "windows":
|
||||
self._urgent_thread = threading.Thread(
|
||||
target=self._run_urgent,
|
||||
name="ui-inspector-evidence-transient",
|
||||
daemon=True,
|
||||
)
|
||||
self._urgent_thread.start()
|
||||
|
||||
def schedule(self, action: Any) -> None:
|
||||
raw = dict(getattr(action, "raw_event", {}) or {})
|
||||
meta = dict(raw.get("meta") or {})
|
||||
context = {
|
||||
"page_id": meta.get("page_id") or "",
|
||||
"document_id": meta.get("document_id") or "",
|
||||
"frame_id": meta.get("frame_id") or "",
|
||||
"source_ts_ms": meta.get("source_ts_ms"),
|
||||
"url": raw.get("url") or "",
|
||||
}
|
||||
scope = dict((raw.get("target") or {}).get("window_scope") or {})
|
||||
context.update(
|
||||
{
|
||||
"target_hwnd": int(scope.get("hwnd") or 0),
|
||||
"target_pid": int(scope.get("pid") or 0),
|
||||
"target_class_name": str(scope.get("class_name") or ""),
|
||||
"target_title": str(scope.get("title") or ""),
|
||||
}
|
||||
)
|
||||
out_queue = (
|
||||
self._urgent_queue
|
||||
if context["target_class_name"] == "SearchResultWindow2"
|
||||
else self._queue
|
||||
)
|
||||
try:
|
||||
out_queue.put_nowait(EvidenceRequest(action.id, context, time.monotonic()))
|
||||
except queue.Full:
|
||||
self.dropped += 1
|
||||
|
||||
def prime(self) -> None:
|
||||
"""Start one background enrichment without creating a semantic action."""
|
||||
try:
|
||||
self._queue.put_nowait(EvidenceRequest("", {}, time.monotonic()))
|
||||
except queue.Full:
|
||||
self.dropped += 1
|
||||
|
||||
def _dump(self, context: dict[str, Any]) -> Any:
|
||||
kwargs = {
|
||||
k: context.get(k)
|
||||
for k in (
|
||||
"page_id", "document_id", "frame_id", "target_hwnd",
|
||||
"target_pid", "target_class_name", "target_title",
|
||||
)
|
||||
if context.get(k)
|
||||
}
|
||||
if kwargs:
|
||||
try:
|
||||
return self.dump_fn(**kwargs)
|
||||
except TypeError as exc:
|
||||
if "unexpected keyword" not in str(exc) and "positional" not in str(exc):
|
||||
raise
|
||||
return self.dump_fn()
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
first = self._queue.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if first is None:
|
||||
self._queue.task_done()
|
||||
self._stop.set()
|
||||
break
|
||||
batch = [first]
|
||||
stop_after_batch = False
|
||||
deadline = time.monotonic() + self.coalesce_sec
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
item = self._queue.get(timeout=max(0.01, deadline - time.monotonic()))
|
||||
except queue.Empty:
|
||||
break
|
||||
if item is None:
|
||||
self._queue.task_done()
|
||||
stop_after_batch = True
|
||||
break
|
||||
batch.append(item)
|
||||
|
||||
groups: dict[str, list[EvidenceRequest]] = {}
|
||||
for item in batch:
|
||||
key = str(item.context.get("page_id") or "active")
|
||||
groups.setdefault(key, []).append(item)
|
||||
for items in groups.values():
|
||||
latest = items[-1]
|
||||
try:
|
||||
dump = self._dump(latest.context)
|
||||
if self.on_dump is not None:
|
||||
self.on_dump(dump)
|
||||
self.capture_count += 1
|
||||
# A desktop provider dump can take seconds. Actions that
|
||||
# arrived while it was running are still earlier than this
|
||||
# eventual observation, so fold them into the same result
|
||||
# instead of immediately launching another identical dump.
|
||||
if str(getattr(self.manager.action_flow, "surface", "")) == "windows":
|
||||
while True:
|
||||
try:
|
||||
late = self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if late is None:
|
||||
self._queue.task_done()
|
||||
stop_after_batch = True
|
||||
break
|
||||
batch.append(late)
|
||||
items.append(late)
|
||||
if not self._stop.is_set():
|
||||
self.manager.attach_eventual_evidence(
|
||||
[item.action_id for item in items], dump=dump,
|
||||
requested_monotonic=min(item.requested_monotonic for item in items),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.failure_count += 1
|
||||
if not self._stop.is_set():
|
||||
self.manager.note_evidence_failure([item.action_id for item in items], str(exc))
|
||||
for _item in batch:
|
||||
self._queue.task_done()
|
||||
if stop_after_batch:
|
||||
self._stop.set()
|
||||
|
||||
def _run_urgent(self) -> None:
|
||||
"""Capture transient native popups independently of a slow main dump."""
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
first = self._urgent_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if first is None:
|
||||
self._urgent_queue.task_done()
|
||||
break
|
||||
batch = [first]
|
||||
# A very short coalesce keeps double-clicks together without waiting
|
||||
# long enough for a search-result popup to disappear.
|
||||
deadline = time.monotonic() + 0.04
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
item = self._urgent_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
time.sleep(0.005)
|
||||
continue
|
||||
if item is None:
|
||||
self._urgent_queue.task_done()
|
||||
break
|
||||
if item.context.get("target_hwnd") == first.context.get("target_hwnd"):
|
||||
batch.append(item)
|
||||
else:
|
||||
# Preserve a different transient window for the next pass.
|
||||
try:
|
||||
self._urgent_queue.put_nowait(item)
|
||||
except queue.Full:
|
||||
self.dropped += 1
|
||||
self._urgent_queue.task_done()
|
||||
break
|
||||
try:
|
||||
dump = self._dump(batch[-1].context)
|
||||
if self.on_dump is not None:
|
||||
self.on_dump(dump)
|
||||
self.capture_count += 1
|
||||
if not self._stop.is_set():
|
||||
self.manager.attach_eventual_evidence(
|
||||
[item.action_id for item in batch],
|
||||
dump=dump,
|
||||
requested_monotonic=min(item.requested_monotonic for item in batch),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.failure_count += 1
|
||||
if not self._stop.is_set():
|
||||
self.manager.note_evidence_failure(
|
||||
[item.action_id for item in batch], str(exc)
|
||||
)
|
||||
finally:
|
||||
for _item in batch:
|
||||
self._urgent_queue.task_done()
|
||||
|
||||
def stop(self, *, timeout: float = 35.0) -> None:
|
||||
if not self._thread:
|
||||
return
|
||||
# Drain the transient-window lane first. A WeCom main-window dump can
|
||||
# be stuck in an uninterruptible provider call for tens of seconds; it
|
||||
# must not cancel the short-lived popup evidence when the user ends the
|
||||
# flow immediately after selecting a result.
|
||||
if self._urgent_thread:
|
||||
try:
|
||||
self._urgent_queue.put(None, timeout=1.0)
|
||||
except queue.Full:
|
||||
pass
|
||||
self._urgent_thread.join(timeout=min(max(timeout, 3.0), 8.0))
|
||||
try:
|
||||
self._queue.put(None, timeout=2.0)
|
||||
except queue.Full:
|
||||
pass
|
||||
self._thread.join(timeout=timeout)
|
||||
if self._thread.is_alive():
|
||||
# A provider dump may be uninterruptible. Prevent a late result
|
||||
# from mutating a manager which is already being finalized.
|
||||
self._stop.set()
|
||||
self._thread = None
|
||||
self._urgent_thread = None
|
||||
Reference in New Issue
Block a user