chore(fleet): sync operator memory, hatch menu dialogs, and watchdog alerts
This commit is contained in:
+28
-13
@@ -168,14 +168,32 @@ def _eval(ws, js, timeout=8.0):
|
||||
return None
|
||||
|
||||
|
||||
VERIFY_TRIES = 10
|
||||
VERIFY_PAUSE = 1.5
|
||||
|
||||
|
||||
def _poll(check, tries=VERIFY_TRIES, pause=VERIFY_PAUSE):
|
||||
"""Poll a state check until true. Fast exit; tolerates slow commits."""
|
||||
for _ in range(tries):
|
||||
try:
|
||||
if check():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(pause)
|
||||
return False
|
||||
|
||||
|
||||
def list_radios(ws):
|
||||
"""All dialog radios with heading/label/value/checked (or [])."""
|
||||
return _eval(ws, JS_LIST_RADIOS) or []
|
||||
rows = _eval(ws, JS_LIST_RADIOS)
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def list_switches(ws):
|
||||
"""All dialog switches with row label + checked (or [])."""
|
||||
return _eval(ws, JS_LIST_SWITCHES) or []
|
||||
rows = _eval(ws, JS_LIST_SWITCHES)
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def radio_state(ws, heading, value):
|
||||
@@ -188,9 +206,9 @@ def radio_state(ws, heading, value):
|
||||
|
||||
|
||||
def set_radio_by_heading(ws, heading, value):
|
||||
"""Set a heading-grouped radio; verify, else trusted click, verify."""
|
||||
"""Set a heading-grouped radio; poll, else trusted click, poll."""
|
||||
if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \
|
||||
and radio_state(ws, heading, value) is True:
|
||||
and _poll(lambda: radio_state(ws, heading, value) is True):
|
||||
return True
|
||||
rect = _eval(ws, JS_RADIO_RECT % (heading, value))
|
||||
if not rect or "x" not in rect:
|
||||
@@ -199,8 +217,7 @@ def set_radio_by_heading(ws, heading, value):
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
return radio_state(ws, heading, value) is True
|
||||
return _poll(lambda: radio_state(ws, heading, value) is True)
|
||||
|
||||
|
||||
def radio_aria_state(ws, name):
|
||||
@@ -214,9 +231,9 @@ def radio_aria_state(ws, name):
|
||||
|
||||
|
||||
def set_radio_by_aria(ws, name):
|
||||
"""Set an aria-labeled radio; verify, else trusted click, verify."""
|
||||
"""Set an aria-labeled radio; poll, else trusted click, poll."""
|
||||
if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \
|
||||
and radio_aria_state(ws, name) is True:
|
||||
and _poll(lambda: radio_aria_state(ws, name) is True):
|
||||
return True
|
||||
rect = _eval(ws, JS_RADIO_ARIA_RECT % name)
|
||||
if not rect or "x" not in rect:
|
||||
@@ -225,8 +242,7 @@ def set_radio_by_aria(ws, name):
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
return radio_aria_state(ws, name) is True
|
||||
return _poll(lambda: radio_aria_state(ws, name) is True)
|
||||
|
||||
|
||||
def switch_state(ws, label):
|
||||
@@ -247,7 +263,7 @@ def set_switch(ws, label, on):
|
||||
if state == bool(on):
|
||||
return True
|
||||
if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \
|
||||
and switch_state(ws, label) is bool(on):
|
||||
and _poll(lambda: switch_state(ws, label) is bool(on)):
|
||||
return True
|
||||
rect = _eval(ws, JS_SWITCH_RECT % label)
|
||||
if not rect or "x" not in rect:
|
||||
@@ -256,5 +272,4 @@ def set_switch(ws, label, on):
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
return switch_state(ws, label) is bool(on)
|
||||
return _poll(lambda: switch_state(ws, label) is bool(on))
|
||||
|
||||
@@ -6,6 +6,7 @@ idempotent (re-clicking the active tab is a harmless no-op), so goto
|
||||
always clicks and reports the click result instead of guessing which
|
||||
tab is active.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from approvals import cdp_evaluate
|
||||
@@ -16,6 +17,10 @@ TAB_NAMES = ["General", "Connectors", "Wallet", "Secure store",
|
||||
"Data controls", "Help & support", "Legal info"]
|
||||
TABS = TAB_NAMES # legacy alias
|
||||
|
||||
# Tab rail buttons carry bare tab names and live outside any nav
|
||||
# landmark, so row matchers exclude them by exact text (live 2026-10-06).
|
||||
_JS_TABS = json.dumps(TAB_NAMES)
|
||||
|
||||
DOCK_MORE_TESTID = "hatch-dock-more"
|
||||
|
||||
JS_DOCK_RECT = ("(() => { const b = document.querySelector("
|
||||
@@ -41,21 +46,26 @@ JS_GOTO_TAB_TMPL = ("(() => { const b = Array.from(document."
|
||||
JS_CLICK_ROW_TMPL = ("((name) => {"
|
||||
" const d = document.querySelector('[role=\"dialog\"]');"
|
||||
" if (!d) return 'NO_DIALOG';"
|
||||
" const TABS = " + _JS_TABS + ";"
|
||||
" const inNav = (el) => !!el.closest("
|
||||
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
|
||||
" const els = Array.from(d.querySelectorAll("
|
||||
"'button, [role=\"button\"], a')).filter(e => !inNav(e));"
|
||||
" const t = els.find(e => (e.innerText || '').trim()"
|
||||
".toLowerCase().startsWith(name.toLowerCase()));"
|
||||
" const t = els.find(e => {"
|
||||
" const txt = (e.innerText || '').trim();"
|
||||
" return !TABS.includes(txt) && txt.toLowerCase()"
|
||||
".startsWith(name.toLowerCase()); });"
|
||||
" if (!t) return 'NO_ROW'; t.click(); return 'CLICKED'; })('%s')")
|
||||
|
||||
JS_DESCRIBE_ROWS = ("(() => {"
|
||||
" const d = document.querySelector('[role=\"dialog\"]');"
|
||||
" if (!d) return null;"
|
||||
" const TABS = " + _JS_TABS + ";"
|
||||
" const inNav = (el) => !!el.closest("
|
||||
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
|
||||
" return Array.from(d.querySelectorAll("
|
||||
"'button, [role=\"button\"], a')).filter(e => !inNav(e))"
|
||||
".filter(e => !TABS.includes((e.innerText || '').trim()))"
|
||||
".map(e => { const lines = (e.innerText || '').trim().split('\\n');"
|
||||
" return {name: (lines[0] || '').slice(0, 80),"
|
||||
" detail: lines.slice(1).join(' / ').slice(0, 120)}; }); })()")
|
||||
@@ -85,7 +95,7 @@ def dialog_text(ws, timeout=8.0, limit=4000):
|
||||
text = cdp_evaluate(ws, JS_DIALOG_TEXT, timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
if not text:
|
||||
if not isinstance(text, str) or not text:
|
||||
return None
|
||||
return text[:limit]
|
||||
|
||||
@@ -135,7 +145,8 @@ def describe_rows(ws, tab=None, timeout=8.0):
|
||||
"""Inventory rows (name/detail) on a tab. [] when unreadable."""
|
||||
if tab is not None and not goto_tab(ws, tab, timeout=timeout):
|
||||
return []
|
||||
return _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout) or []
|
||||
rows = _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout)
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def go_back(ws):
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Data controls tab: model-improvement switch (read-only otherwise).
|
||||
|
||||
The switch row label is not yet pinned from recon, so resolution
|
||||
prefers the single switch on the tab and falls back to keyword
|
||||
match. Import/Delete rows are inventoried, never touched.
|
||||
The switch label is pinned from live recon; resolution prefers it
|
||||
and falls back to single-switch, then keyword match. Import/Delete
|
||||
rows are inventoried, never touched.
|
||||
"""
|
||||
from hatch_menu import controls, dialog
|
||||
|
||||
TAB = "Data controls"
|
||||
SWITCH_LABEL = "Help improve our AI models"
|
||||
_KEYWORDS = ("improv", "train", "model", "data", "usage")
|
||||
|
||||
|
||||
@@ -15,6 +16,11 @@ def _resolve(ws):
|
||||
if not dialog.goto_tab(ws, TAB):
|
||||
return None
|
||||
switches = controls.list_switches(ws)
|
||||
for s in switches:
|
||||
blob = ((s.get("label") or "") + " "
|
||||
+ (s.get("aria") or "")).lower()
|
||||
if SWITCH_LABEL.lower() in blob:
|
||||
return s
|
||||
if len(switches) == 1:
|
||||
return switches[0]
|
||||
for kw in _KEYWORDS:
|
||||
|
||||
@@ -10,7 +10,7 @@ from hatch_menu import controls, dialog
|
||||
|
||||
TAB = "General"
|
||||
|
||||
THEME_VALUES = ("match", "default", "blue", "purple", "pink",
|
||||
THEME_VALUES = ("avatar", "default", "blue", "purple", "pink",
|
||||
"orange", "green", "beige", "monochrome")
|
||||
|
||||
_FREE_RE = re.compile(r"\bfree plan\b", re.IGNORECASE)
|
||||
|
||||
@@ -25,7 +25,18 @@ ADV_LABELS = {"transparent_proxy": "Transparent proxy",
|
||||
"tls_interception": "TLS interception",
|
||||
"sni_mismatch_rejection": "SNI mismatch rejection"}
|
||||
|
||||
PROTOCOL_SLUGS = {"Model Context Protocol servers (SSE)": "mcp-sse",
|
||||
# Row titles (first line of each protocol row) pinned live 2026-10-06:
|
||||
# network primitives on every node checked; MCP titles kept
|
||||
# defensively in case they appear on other plans/accounts.
|
||||
PROTOCOL_SLUGS = {"Outbound SSH": "outbound-ssh",
|
||||
"Outgoing email (SMTP)": "smtp",
|
||||
"Email mailbox access (IMAP, POP3)": "imap-pop3",
|
||||
"Database connections": "database",
|
||||
"File transfer (FTP)": "ftp",
|
||||
"External DNS lookups": "dns",
|
||||
"Other TCP connections": "other-tcp",
|
||||
"Other UDP traffic": "other-udp",
|
||||
"Model Context Protocol servers (SSE)": "mcp-sse",
|
||||
"Model Context Protocol servers (Streamable HTTP)":
|
||||
"mcp-streamable",
|
||||
"Agent Skills endpoints": "agent-skills",
|
||||
@@ -35,20 +46,16 @@ PROTOCOL_SLUGS = {"Model Context Protocol servers (SSE)": "mcp-sse",
|
||||
JS_WEBSITES = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
return Array.from(d.querySelectorAll('button')).filter(b =>
|
||||
['allow', 'ask', 'deny'].includes((b.getAttribute('aria-label') || '')
|
||||
.trim().toLowerCase())).map(b => {
|
||||
let el = b.parentElement, host = '', depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const t = (el.innerText || '').trim().split('\\n')[0] || '';
|
||||
if (t && t.includes('.') && t.length < 120) { host = t; break; }
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return {host: host, mode: (b.getAttribute('aria-label') || '').trim(),
|
||||
x: b.getBoundingClientRect().x + b.getBoundingClientRect().width / 2,
|
||||
y: b.getBoundingClientRect().y + b.getBoundingClientRect().height / 2};
|
||||
});
|
||||
const out = [];
|
||||
for (const b of d.querySelectorAll('button')) {
|
||||
const m = (b.getAttribute('aria-label') || '').match(
|
||||
/^Change permission mode for (.+),\\s*(Allow|Ask|Deny)$/i);
|
||||
if (!m) continue;
|
||||
const r = b.getBoundingClientRect();
|
||||
out.push({host: m[1].trim(), mode: m[2],
|
||||
x: r.x + r.width / 2, y: r.y + r.height / 2});
|
||||
}
|
||||
return out;
|
||||
})()"""
|
||||
|
||||
JS_MODE_MENU = """(() => {
|
||||
@@ -64,27 +71,19 @@ JS_CLICK_MODE = """((mode) => {
|
||||
return 'CLICKED';
|
||||
})('%s')"""
|
||||
|
||||
JS_MODE_RECT = """((mode) => {
|
||||
const m = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.find(el => (el.innerText || '').trim() === mode);
|
||||
if (!m) return null;
|
||||
const r = m.getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
})('%s')"""
|
||||
|
||||
JS_PROTO_ROWS = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return null;
|
||||
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
|
||||
let el = s.parentElement, label = '', depth = 0;
|
||||
let el = s.parentElement, title = '', depth = 0;
|
||||
while (el && el !== d && depth < 6) {
|
||||
const t = (el.innerText || '').trim().replace(/\\s+/g, ' ');
|
||||
if (t && t.length < 250) { label = t; break; }
|
||||
const t = (el.innerText || '').trim().split('\\n')[0] || '';
|
||||
if (t) { title = t.slice(0, 80); break; }
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
const r = s.getBoundingClientRect();
|
||||
return {label: label.slice(0, 120),
|
||||
return {title: title,
|
||||
checked: s.getAttribute('aria-checked') === 'true',
|
||||
x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
});
|
||||
@@ -98,23 +97,55 @@ def _eval(ws, js, timeout=8.0):
|
||||
return None
|
||||
|
||||
|
||||
def _slug(label):
|
||||
def _stable_rows(ws, js, retries=4, pause=1.5):
|
||||
"""Repeat a row read until two consecutive reads agree.
|
||||
|
||||
Guards mid-animation partial DOM (innerText shifts while the
|
||||
sub-page slides in). Returns the agreed list, or None.
|
||||
"""
|
||||
last = "sentinel"
|
||||
for _ in range(retries):
|
||||
rows = _eval(ws, js)
|
||||
if isinstance(rows, list) and rows == last:
|
||||
return rows
|
||||
last = rows if isinstance(rows, list) else "sentinel"
|
||||
time.sleep(pause)
|
||||
return last if isinstance(last, list) else None
|
||||
|
||||
|
||||
def _slug(title):
|
||||
"""Protocol slug: registry hit, else slugified, else None."""
|
||||
if label in PROTOCOL_SLUGS:
|
||||
return PROTOCOL_SLUGS[label]
|
||||
if title in PROTOCOL_SLUGS:
|
||||
return PROTOCOL_SLUGS[title]
|
||||
clean = re.sub(r"[^a-z0-9]+", "-",
|
||||
label.strip().lower()).strip("-")
|
||||
title.strip().lower()).strip("-")
|
||||
return clean or None
|
||||
|
||||
|
||||
def _canon_mode(mode):
|
||||
"""Canonical Allow/Ask/Deny (case-insensitive); passthrough else."""
|
||||
for m in WEBSITE_MODES:
|
||||
if (mode or "").lower() == m.lower():
|
||||
return m
|
||||
return mode
|
||||
|
||||
|
||||
def resolve_protocol(name):
|
||||
"""Slug or label fragment -> row label, None when unresolvable."""
|
||||
"""Slug/title -> row title, None when unresolvable.
|
||||
|
||||
Exact slug or title first; then a unique case-insensitive
|
||||
substring over titles+slugs (so 'ssh' finds Outbound SSH).
|
||||
"""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
want = name.strip().lower()
|
||||
for label, slug in PROTOCOL_SLUGS.items():
|
||||
if want == slug or want == label.lower():
|
||||
return label
|
||||
for title, slug in PROTOCOL_SLUGS.items():
|
||||
if want == slug or want == title.lower():
|
||||
return title
|
||||
hits = [t for t, s in PROTOCOL_SLUGS.items()
|
||||
if want in t.lower() or want in s]
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
@@ -195,8 +226,7 @@ def _websites_raw(ws):
|
||||
"""Drill into Websites; rows or None (stays on sub-page)."""
|
||||
if not dialog.click_row(ws, "Websites", TAB):
|
||||
return None
|
||||
time.sleep(0.6)
|
||||
return _eval(ws, JS_WEBSITES)
|
||||
return _stable_rows(ws, JS_WEBSITES)
|
||||
|
||||
|
||||
def websites(ws):
|
||||
@@ -204,7 +234,8 @@ def websites(ws):
|
||||
rows = _websites_raw(ws)
|
||||
if rows is None:
|
||||
return []
|
||||
out = [{"host": r.get("host"), "mode": r.get("mode")} for r in rows]
|
||||
out = [{"host": r.get("host"), "mode": _canon_mode(r.get("mode"))}
|
||||
for r in rows]
|
||||
_back_to_root(ws)
|
||||
return out
|
||||
|
||||
@@ -218,7 +249,12 @@ def website_mode(ws, host):
|
||||
|
||||
|
||||
def set_website_mode(ws, host, mode):
|
||||
"""Set one host mode via the mode chooser. Verify + readback. Bool."""
|
||||
"""Set one host mode via the mode chooser. Bool.
|
||||
|
||||
One-way for Ask/Deny: the override row leaves the allowed list
|
||||
(no add UI), so removal verifies by absence. No-op when already
|
||||
there; absent hosts fail (nothing to click).
|
||||
"""
|
||||
if mode not in WEBSITE_MODES:
|
||||
return False
|
||||
rows = _websites_raw(ws)
|
||||
@@ -230,14 +266,18 @@ def set_website_mode(ws, host, mode):
|
||||
if target is None:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
if _canon_mode(target.get("mode")) == mode:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
try:
|
||||
real_click(ws, target["x"], target["y"])
|
||||
except Exception:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
time.sleep(0.8)
|
||||
items = _eval(ws, JS_MODE_MENU) or []
|
||||
texts = [(i.get("text") or "") for i in items]
|
||||
items = _eval(ws, JS_MODE_MENU)
|
||||
texts = [(i.get("text") or "") for i in items] \
|
||||
if isinstance(items, list) else []
|
||||
if mode not in texts:
|
||||
escape(ws)
|
||||
_back_to_root(ws)
|
||||
@@ -246,26 +286,19 @@ def set_website_mode(ws, host, mode):
|
||||
escape(ws)
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
rows = _eval(ws, JS_WEBSITES) or []
|
||||
cur = next(((r.get("mode")) for r in rows
|
||||
if (r.get("host") or "").lower() == host.lower()),
|
||||
None)
|
||||
if cur == mode:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
rect = _eval(ws, JS_MODE_RECT % mode)
|
||||
if rect and "x" in rect:
|
||||
try:
|
||||
real_click(ws, rect["x"], rect["y"])
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.6)
|
||||
rows = _eval(ws, JS_WEBSITES) or []
|
||||
cur = next(((r.get("mode")) for r in rows
|
||||
for _ in range(5):
|
||||
time.sleep(2.0)
|
||||
rows = _eval(ws, JS_WEBSITES)
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
cur = next((_canon_mode(r.get("mode")) for r in rows
|
||||
if (r.get("host") or "").lower() == host.lower()),
|
||||
None)
|
||||
if cur == mode:
|
||||
if mode in ("Ask", "Deny"):
|
||||
if cur is None:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
elif cur == mode:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
escape(ws)
|
||||
@@ -277,36 +310,35 @@ def _protocols_raw(ws):
|
||||
"""Drill into protocols; rows or None (stays on sub-page)."""
|
||||
if not dialog.click_row(ws, "Direct network protocols", TAB):
|
||||
return None
|
||||
time.sleep(0.6)
|
||||
return _eval(ws, JS_PROTO_ROWS)
|
||||
return _stable_rows(ws, JS_PROTO_ROWS)
|
||||
|
||||
|
||||
def protocols(ws):
|
||||
"""[{slug, label, on}] (back at root afterwards)."""
|
||||
"""[{slug, title, on}] (back at root afterwards)."""
|
||||
rows = _protocols_raw(ws)
|
||||
if rows is None:
|
||||
return []
|
||||
out = [{"slug": _slug(r.get("label", "")),
|
||||
"label": r.get("label", ""),
|
||||
out = [{"slug": _slug(r.get("title", "")),
|
||||
"title": r.get("title", ""),
|
||||
"on": "on" if r.get("checked") else "off"} for r in rows]
|
||||
_back_to_root(ws)
|
||||
return out
|
||||
|
||||
|
||||
def protocol_state(ws, label):
|
||||
"""on/off for one protocol row label, None when absent."""
|
||||
def protocol_state(ws, title):
|
||||
"""on/off for one protocol row title, None when absent."""
|
||||
for row in protocols(ws):
|
||||
if row.get("label") == label:
|
||||
if row.get("title") == title:
|
||||
return row.get("on")
|
||||
return None
|
||||
|
||||
|
||||
def set_protocol(ws, label, on):
|
||||
def set_protocol(ws, title, on):
|
||||
"""Set one protocol switch in place; readback before returning."""
|
||||
rows = _protocols_raw(ws)
|
||||
if rows is None:
|
||||
return False
|
||||
target = next((r for r in rows if r.get("label") == label), None)
|
||||
target = next((r for r in rows if r.get("title") == title), None)
|
||||
if target is None:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
@@ -319,12 +351,19 @@ def set_protocol(ws, label, on):
|
||||
except Exception:
|
||||
_back_to_root(ws)
|
||||
return False
|
||||
time.sleep(0.6)
|
||||
rows = _eval(ws, JS_PROTO_ROWS) or []
|
||||
cur = next((r for r in rows if r.get("label") == label), None)
|
||||
ok = cur is not None and bool(cur.get("checked")) == want
|
||||
for _ in range(8):
|
||||
time.sleep(2.0)
|
||||
rows = _eval(ws, JS_PROTO_ROWS)
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
cur = next((r for r in rows if r.get("title") == title), None)
|
||||
if cur is not None and bool(cur.get("checked")) == want:
|
||||
_back_to_root(ws)
|
||||
return True
|
||||
_back_to_root(ws)
|
||||
return ok
|
||||
# In-dialog verify missed (slow commit or commit-on-close); the
|
||||
# toggles-level fresh readback is the source of truth.
|
||||
return False
|
||||
|
||||
|
||||
def manage_counts(ws):
|
||||
|
||||
@@ -182,6 +182,10 @@ def get_toggle(node, name):
|
||||
else:
|
||||
value = None
|
||||
if value is None:
|
||||
if kind == "website":
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "host not in Websites list (effective: "
|
||||
"permissions.web_access default)"}
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "toggle not readable (site changed?)"}
|
||||
return {"ok": True, "node": node, "toggle": name, "value": value}
|
||||
@@ -226,15 +230,19 @@ def set_toggle(node, name, value):
|
||||
ok = _PERM.set_protocol(ws, spec["label"], want == "on")
|
||||
else:
|
||||
ok = False
|
||||
if not ok:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "set failed verification (site changed?)"}
|
||||
# The fresh-session readback is the source of truth: switch
|
||||
# commits can land slowly or on dialog close, after the
|
||||
# in-flow verify had its chance.
|
||||
readback = get_toggle(node, name)
|
||||
if not readback.get("ok") or readback.get("value") != want:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "readback mismatch (want %r, got %r)"
|
||||
% (want, readback.get("value"))}
|
||||
return {"ok": True, "node": node, "toggle": name, "value": want}
|
||||
if readback.get("ok") and readback.get("value") == want:
|
||||
out = {"ok": True, "node": node, "toggle": name,
|
||||
"value": want}
|
||||
if not ok:
|
||||
out["readback_only"] = True
|
||||
return out
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "readback mismatch (want %r, got %r)"
|
||||
% (want, readback.get("value"))}
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "toggle": name,
|
||||
"error": "%s: %s" % (type(e).__name__, e)}
|
||||
|
||||
Reference in New Issue
Block a user