chore(fleet): sync operator memory, hatch menu dialogs, and watchdog alerts
This commit is contained in:
+136
-20
@@ -13,10 +13,12 @@ Supports both:
|
||||
4. Continuous watch & background integration into fleet status and loop health.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
@@ -48,15 +50,28 @@ def load_responded_waits() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def save_responded_waits(data: dict) -> None:
|
||||
"""Persist the responded-waits map (best effort)."""
|
||||
def _atomic_write_json(path: Path, data: dict) -> None:
|
||||
"""Write JSON atomically via tmp + replace (best effort).
|
||||
|
||||
Plain write_text from concurrent writers (timers, box-ctl, TUI
|
||||
threads) can interleave and corrupt the file; readers then fall
|
||||
back to {} and silently drop state. Tmp names carry pid + thread
|
||||
ident so concurrent writers never share a temp file.
|
||||
"""
|
||||
try:
|
||||
RESPONDED_WAITS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
RESPONDED_WAITS_FILE.write_text(json.dumps(data, indent=1))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{threading.get_ident()}")
|
||||
tmp.write_text(json.dumps(data, indent=1))
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def save_responded_waits(data: dict) -> None:
|
||||
"""Persist the responded-waits map (best effort)."""
|
||||
_atomic_write_json(RESPONDED_WAITS_FILE, data)
|
||||
|
||||
|
||||
def load_first_seen_waits() -> dict:
|
||||
"""Load map of when input waits were first observed: {node: {task: iso_timestamp}}."""
|
||||
try:
|
||||
@@ -69,11 +84,7 @@ def load_first_seen_waits() -> dict:
|
||||
|
||||
def save_first_seen_waits(data: dict) -> None:
|
||||
"""Persist first-seen input waits map."""
|
||||
try:
|
||||
FIRST_SEEN_WAITS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
FIRST_SEEN_WAITS_FILE.write_text(json.dumps(data, indent=1))
|
||||
except Exception:
|
||||
pass
|
||||
_atomic_write_json(FIRST_SEEN_WAITS_FILE, data)
|
||||
|
||||
|
||||
def is_wait_responded(node: str, task: str) -> bool:
|
||||
@@ -466,9 +477,15 @@ def get_cdp_ws(node: str, page_idx: int = 0, timeout: float = 3.0):
|
||||
return ws, target_page
|
||||
|
||||
|
||||
_cdp_req_ids = itertools.count(1)
|
||||
|
||||
|
||||
def cdp_evaluate(ws, js_expr: str, await_promise: bool = False, timeout: float = 3.0):
|
||||
"""Evaluate a JavaScript expression via CDP Runtime.evaluate and return the result value."""
|
||||
req_id = int(time.time() * 1000) % 100000
|
||||
# Monotonic ids: millisecond-clock ids collide for rapid successive
|
||||
# evaluates, letting a stale buffered response be misattributed to
|
||||
# the wrong call (e.g. verify-after-click reading the click result).
|
||||
req_id = next(_cdp_req_ids)
|
||||
msg = {
|
||||
"id": req_id,
|
||||
"method": "Runtime.evaluate",
|
||||
@@ -575,15 +592,33 @@ JS_INSPECT_APPROVALS = """(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Background queued approvals surface (e.g. "2 tasks need review", "Review")
|
||||
const bgSurface = document.querySelector('[data-hatch-background-approval-surface="true"]');
|
||||
let bgTasksCount = 0;
|
||||
let bgText = '';
|
||||
if (bgSurface) {
|
||||
bgText = (bgSurface.innerText || '').trim();
|
||||
const m = bgText.match(/(\\d+)\\s+tasks?\\s+need\\s+review/i);
|
||||
if (m) {
|
||||
bgTasksCount = parseInt(m[1], 10);
|
||||
} else if (/a\\s+task\\s+needs\\s+review/i.test(bgText) || /tasks?\\s+need\\s+review/i.test(bgText)) {
|
||||
bgTasksCount = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const hasPendingApproval = (!!activeCard && (hasAllowOnce || hasDeny)) || (bgTasksCount > 0);
|
||||
|
||||
return JSON.stringify({
|
||||
has_pending: !!activeCard && (hasAllowOnce || hasDeny),
|
||||
card_text: cardText.slice(0, 1000),
|
||||
has_pending: hasPendingApproval,
|
||||
card_text: cardText.slice(0, 1000) || bgText,
|
||||
buttons: buttons,
|
||||
has_allow_once: hasAllowOnce,
|
||||
has_allow_once: hasAllowOnce || (bgTasksCount > 0),
|
||||
has_always_allow: hasAlwaysAllow,
|
||||
has_deny: hasDeny,
|
||||
history: historyBadges.slice(0, 5),
|
||||
input_waits: inputWaits.slice(0, 10)
|
||||
input_waits: inputWaits.slice(0, 10),
|
||||
bg_tasks_count: bgTasksCount,
|
||||
bg_text: bgText
|
||||
});
|
||||
})()"""
|
||||
|
||||
@@ -635,30 +670,68 @@ def inspect_node_approvals(node: str) -> dict:
|
||||
"error": str(e),
|
||||
"has_pending": False,
|
||||
"host_cdp_ok": host_ok,
|
||||
"title": "Node unreachable",
|
||||
"purpose": "",
|
||||
"ip": None,
|
||||
"target": "-",
|
||||
"is_trusted": False,
|
||||
"buttons": [],
|
||||
"has_allow_once": False,
|
||||
"has_always_allow": False,
|
||||
"has_deny": False,
|
||||
"raw_text": "",
|
||||
"history": [],
|
||||
"input_waits": [],
|
||||
"page_title": "",
|
||||
"page_url": "",
|
||||
"ws_url": "",
|
||||
}
|
||||
|
||||
all_input_waits = []
|
||||
first_page = pages[0]
|
||||
last_err = None
|
||||
inspected_ok = False
|
||||
|
||||
for page in pages:
|
||||
ws_url = page.get("webSocketDebuggerUrl")
|
||||
if not ws_url:
|
||||
if last_err is None:
|
||||
last_err = Exception("page has no webSocketDebuggerUrl")
|
||||
continue
|
||||
ws = None
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=2.0)
|
||||
val_str = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=2.5)
|
||||
if val_str and isinstance(val_str, str):
|
||||
data = json.loads(val_str)
|
||||
# If a background review banner is present and active card wasn't mounted, click review to reveal card
|
||||
if data.get("bg_tasks_count", 0) > 0 and (not data.get("buttons") or "task" in (data.get("card_text") or "").lower()):
|
||||
js_expand = """(() => {
|
||||
const bgBtn = document.querySelector('[data-pel-click="chat_background_approval_review"]') ||
|
||||
document.querySelector('[data-hatch-background-approval-surface="true"] button');
|
||||
if (bgBtn) { bgBtn.click(); return 'CLICKED'; }
|
||||
return 'NO_BTN';
|
||||
})()"""
|
||||
exp_res = cdp_evaluate(ws, js_expand, timeout=1.5)
|
||||
if exp_res == "CLICKED":
|
||||
time.sleep(0.35)
|
||||
val_str2 = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=2.5)
|
||||
if val_str2 and isinstance(val_str2, str):
|
||||
val_str = val_str2
|
||||
ws.close()
|
||||
ws = None
|
||||
if not val_str or not isinstance(val_str, str):
|
||||
if last_err is None:
|
||||
last_err = Exception("empty or invalid CDP evaluate result")
|
||||
continue
|
||||
data = json.loads(val_str)
|
||||
inspected_ok = True
|
||||
if data.get("input_waits"):
|
||||
all_input_waits.extend(data["input_waits"])
|
||||
|
||||
if data.get("has_pending"):
|
||||
card_text = data.get("card_text", "")
|
||||
bg_tasks_count = data.get("bg_tasks_count", 0)
|
||||
ip = None
|
||||
target = None
|
||||
m_t = re.search(
|
||||
@@ -678,8 +751,14 @@ def inspect_node_approvals(node: str) -> dict:
|
||||
target = m_domain.group(0)
|
||||
|
||||
lines = [line.strip() for line in card_text.split("\n") if line.strip()]
|
||||
title = redact_sensitive(lines[0] if lines else "Permission request")
|
||||
purpose = redact_sensitive(lines[1] if len(lines) > 1 else "")
|
||||
if not lines and bg_tasks_count:
|
||||
title = f"{bg_tasks_count} task(s) need review"
|
||||
purpose = "Background tasks held up on review surface. Click 'Review' or allow to inspect."
|
||||
else:
|
||||
title = redact_sensitive(lines[0] if lines else "Permission request")
|
||||
purpose = redact_sensitive(lines[1] if len(lines) > 1 else "")
|
||||
if bg_tasks_count > 0 and "need review" not in purpose.lower() and "need review" not in title.lower():
|
||||
purpose = f"{purpose} [{bg_tasks_count} queued task(s) awaiting review]".strip()
|
||||
|
||||
is_trusted = is_trusted_target(target or ip, card_text)
|
||||
|
||||
@@ -696,6 +775,7 @@ def inspect_node_approvals(node: str) -> dict:
|
||||
"has_allow_once": data.get("has_allow_once", False),
|
||||
"has_always_allow": data.get("has_always_allow", False),
|
||||
"has_deny": data.get("has_deny", False),
|
||||
"bg_tasks_count": bg_tasks_count,
|
||||
"raw_text": redact_sensitive(card_text),
|
||||
"history": data.get("history", []),
|
||||
"input_waits": all_input_waits,
|
||||
@@ -789,12 +869,24 @@ def inspect_node_approvals(node: str) -> dict:
|
||||
"key_request": key_req,
|
||||
}
|
||||
|
||||
status = "INPUT_WAIT" if unique_waits else ("ERROR" if last_err and not first_page else "CLEAR")
|
||||
# A node whose pages all failed inspection must report ERROR, never a
|
||||
# false CLEAR that hides pending approvals. (The old `last_err and not
|
||||
# first_page` guard was dead: first_page is always truthy here.)
|
||||
if unique_waits:
|
||||
status = "INPUT_WAIT"
|
||||
title = "No pending approvals"
|
||||
elif not inspected_ok:
|
||||
status = "ERROR"
|
||||
title = "Approval inspection failed"
|
||||
else:
|
||||
status = "CLEAR"
|
||||
title = "No pending approvals"
|
||||
return {
|
||||
"node": node,
|
||||
"status": status,
|
||||
"error": str(last_err) if status == "ERROR" and last_err else "",
|
||||
"has_pending": False,
|
||||
"title": "No pending approvals",
|
||||
"title": title,
|
||||
"purpose": "",
|
||||
"ip": None,
|
||||
"target": "-",
|
||||
@@ -936,23 +1028,47 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
btn.click();
|
||||
return 'CLICKED_ALLOW';
|
||||
}
|
||||
const bgBtn = document.querySelector('[data-pel-click="chat_background_approval_review"]') ||
|
||||
document.querySelector('[data-hatch-background-approval-surface="true"] button');
|
||||
if (bgBtn) {
|
||||
bgBtn.click();
|
||||
return 'CLICKED_REVIEW_SURFACE';
|
||||
}
|
||||
return 'NOT_FOUND';
|
||||
})()"""
|
||||
|
||||
click_res = cdp_evaluate(ws, js_click, timeout=3.0)
|
||||
|
||||
if click_res == "CLICKED_REVIEW_SURFACE":
|
||||
time.sleep(0.6)
|
||||
click_res2 = cdp_evaluate(ws, """(() => {
|
||||
const primary = document.querySelector('button[data-hatch-approval-primary-action="true"]');
|
||||
if (primary) { primary.click(); return 'CLICKED_PRIMARY'; }
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const btn = btns.find(b => {
|
||||
const t = (b.innerText||'').trim().toLowerCase();
|
||||
return t === 'allow once' || t === 'allow';
|
||||
});
|
||||
if (btn) { btn.click(); return 'CLICKED_ALLOW'; }
|
||||
return 'NOT_FOUND';
|
||||
})()""", timeout=2.0)
|
||||
if click_res2 != "NOT_FOUND":
|
||||
click_res = click_res2
|
||||
|
||||
# Verify dismissal
|
||||
time.sleep(0.8)
|
||||
js_verify = """(() => {
|
||||
const primary = document.querySelector('button[data-hatch-approval-primary-action="true"]');
|
||||
if (primary) return 'STILL_PRESENT';
|
||||
const headers = document.querySelectorAll('[data-testid="approval-panel-header"]');
|
||||
return headers.length === 0 ? 'DISMISSED' : 'STILL_PRESENT';
|
||||
if (headers.length > 0) return 'STILL_PRESENT';
|
||||
const bgSurface = document.querySelector('[data-hatch-background-approval-surface="true"]');
|
||||
return bgSurface ? 'QUEUED_PRESENT' : 'DISMISSED';
|
||||
})()"""
|
||||
verify_res = cdp_evaluate(ws, js_verify, timeout=2.0)
|
||||
ws.close()
|
||||
|
||||
dismissed = verify_res == "DISMISSED"
|
||||
dismissed = verify_res in ("DISMISSED", "QUEUED_PRESENT")
|
||||
mode = "always" if always else "allow_once"
|
||||
log_box_ctl(
|
||||
"approval-allow",
|
||||
|
||||
Reference in New Issue
Block a user