feat(hybrid-gateway): integrate muse-cli with Cloudflare netns isolation, symmetric sidechat routing, and 646-pip sync unblock
This commit is contained in:
+125
-1
@@ -16,6 +16,9 @@ Usage:
|
||||
muse-chat-api.py --account <agent> wait [timeout]
|
||||
muse-chat-api.py --account <agent> approvals # check pending approvals
|
||||
muse-chat-api.py --account <agent> upload <file> [--message txt] [--dry-run]
|
||||
|
||||
Exit codes: 0 ok, 1 error, 2 APPROVAL_NEEDED (human decision),
|
||||
3 DOM_NOT_READY (browser not in expected chat state - safe to retry).
|
||||
"""
|
||||
import json, urllib.request, websocket, time, sys, argparse, importlib.util
|
||||
import os
|
||||
@@ -146,6 +149,120 @@ def check_approvals(ws):
|
||||
|
||||
return actions
|
||||
|
||||
# Exit code for "browser DOM not ready" — distinct from 1 (generic error)
|
||||
# and 2 (APPROVAL_NEEDED, needs a human). DOM_NOT_READY is safe to retry
|
||||
# after a few seconds: the React app may still be hydrating (S3 partial
|
||||
# load) or the Warp tunnel may have just hiccupped. dm.py treats any
|
||||
# non-"sent" send output as a failed attempt and retries, so this is a
|
||||
# fail-closed retry signal, not a crash.
|
||||
DOM_NOT_READY_EXIT = 3
|
||||
|
||||
# Shimmer spans (span.animate-pulse-light) exist in the settled state too
|
||||
# (2 observed — avatar/media skeleton slots, not load progress). Only a
|
||||
# mass-skeleton count indicates a stuck/broken render. Heuristic threshold
|
||||
# from docs/DOM-EDGE-STATES.md 1.1; the settled-state signature below is
|
||||
# the primary readiness signal.
|
||||
SHIMMER_STORM_THRESHOLD = 30
|
||||
|
||||
|
||||
def chat_ready_probe(ws):
|
||||
"""One-shot DOM readiness probe. Returns a dict of observed state, or
|
||||
{} if the page could not be evaluated at all."""
|
||||
result = ev1(ws, """(() => {
|
||||
const q = s => { try { return document.querySelector(s); } catch(e) { return null; } };
|
||||
const qa = s => { try { return document.querySelectorAll(s); } catch(e) { return []; } };
|
||||
const title = document.title || '';
|
||||
const url = window.location.href || '';
|
||||
const switcher = !!q('[data-testid="hatch-chat-switcher-trigger"]');
|
||||
const buttons = qa('button').length;
|
||||
const msglog = !!q('div[role="log"]');
|
||||
const composer = !!(q('[contenteditable="true"]') ||
|
||||
q('textarea[placeholder*="Message"]') ||
|
||||
q('div[role="textbox"]'));
|
||||
const shimmer = qa('span.animate-pulse-light').length;
|
||||
let alertText = '';
|
||||
try {
|
||||
alertText = [...qa('[role="alert"]')]
|
||||
.filter(e => e.tagName !== 'SCRIPT' && (e.innerText||'').trim().length > 0)
|
||||
.slice(0, 2).map(e => e.innerText.slice(0, 160)).join(' | ');
|
||||
} catch(e) {}
|
||||
let onLine = true;
|
||||
try { onLine = navigator.onLine; } catch(e) {}
|
||||
return JSON.stringify({title, url, switcher, buttons, msglog,
|
||||
composer, shimmer, alertText, onLine});
|
||||
})()""")
|
||||
try:
|
||||
return json.loads(result) if result else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def classify_chat_state(p):
|
||||
"""Classify a readiness probe. Returns (state, diagnosis). READY is the
|
||||
only state a send may proceed from. Reference: docs/DOM-EDGE-STATES.md.
|
||||
Note: 'Chat \u2014' is the 'Chat \u2014 <identity>' title prefix."""
|
||||
if not p:
|
||||
return ("NO_PROBE",
|
||||
"CDP evaluate returned nothing - page blank, navigating, or CDP wedged")
|
||||
title = p.get("title", "")
|
||||
url = p.get("url", "")
|
||||
if title == "muse.ai":
|
||||
return ("LANDING",
|
||||
"browser is on the muse.ai landing page, not in chat (state: LANDING)")
|
||||
if not p.get("onLine", True):
|
||||
return ("OFFLINE",
|
||||
"navigator.onLine is false - browser reports no network")
|
||||
if p.get("alertText"):
|
||||
return ("REAL_ERROR",
|
||||
"page shows an error banner: %s" % p["alertText"][:160])
|
||||
if "/thread/new" in url:
|
||||
# Legitimate only right after `sidechat create`: the new-thread
|
||||
# composer is up and the title has settled. A stripped /thread/new
|
||||
# (no title, no composer) is the S8 broken state.
|
||||
if p.get("composer") and "Chat \u2014" in title:
|
||||
return ("READY", "new-thread composer state (/thread/new)")
|
||||
return ("S8_STRIPPED",
|
||||
"stripped /thread/new page - no composer/title (state: S8)")
|
||||
switcher = p.get("switcher", False)
|
||||
buttons = p.get("buttons", 0)
|
||||
if title == "Muse" and (not switcher or buttons < 30):
|
||||
return ("S3_PARTIAL",
|
||||
"React still hydrating: title 'Muse', %d buttons, switcher=%s (state: S3)"
|
||||
% (buttons, switcher))
|
||||
if not ("Chat \u2014" in title and switcher and buttons > 30 and p.get("msglog")):
|
||||
return ("NOT_SETTLED",
|
||||
"settled-state signature not met (title=%r switcher=%s buttons=%d msglog=%s)" %
|
||||
(title[:40], switcher, buttons, p.get("msglog", False)))
|
||||
if p.get("shimmer", 0) > SHIMMER_STORM_THRESHOLD:
|
||||
return ("SHIMMER_STORM",
|
||||
"mass skeleton render: %d shimmer spans (settled has ~2)" % p["shimmer"])
|
||||
if not p.get("composer"):
|
||||
return ("COMPOSER_MISSING",
|
||||
"chat settled but no composer input found - send has nowhere to type")
|
||||
return ("READY", "settled chat state (title=%r)" % title[:40])
|
||||
|
||||
|
||||
def assert_chat_ready(ws, context="send"):
|
||||
"""Fail-closed React-readiness gate ("matching chromebox").
|
||||
|
||||
Call before any DOM mutation (send, upload). On mismatch prints a
|
||||
structured diagnosis to stderr and exits DOM_NOT_READY_EXIT (3) - a
|
||||
retryable signal, distinct from APPROVAL_NEEDED (2, needs a human).
|
||||
S3 partial load gets one 5s grace re-probe (transient render phase);
|
||||
everything else fails immediately.
|
||||
"""
|
||||
probe = chat_ready_probe(ws)
|
||||
state, detail = classify_chat_state(probe)
|
||||
if state == "S3_PARTIAL":
|
||||
time.sleep(5)
|
||||
probe = chat_ready_probe(ws)
|
||||
state, detail = classify_chat_state(probe)
|
||||
if state != "READY":
|
||||
print("DOM_NOT_READY [%s] %s" % (state, detail), file=sys.stderr)
|
||||
print("DOM_NOT_READY probe: %s" % json.dumps(probe), file=sys.stderr)
|
||||
sys.exit(DOM_NOT_READY_EXIT)
|
||||
return probe
|
||||
|
||||
def cmd_approvals(ws):
|
||||
"""Check and handle pending approvals."""
|
||||
actions = check_approvals(ws)
|
||||
@@ -160,7 +277,12 @@ def cmd_approvals(ws):
|
||||
sys.exit(2)
|
||||
|
||||
def cmd_send(ws, message):
|
||||
# Check approvals first
|
||||
# React-readiness gate ("matching chromebox"): fail closed if the
|
||||
# browser is not in the expected chat state (landing page,
|
||||
# partial load, partition). Exit 3 = safe to retry, unlike
|
||||
# APPROVAL_NEEDED (2, needs a human).
|
||||
assert_chat_ready(ws, context="send")
|
||||
# Check approvals
|
||||
actions = check_approvals(ws)
|
||||
for dialog, trusted, action in actions:
|
||||
if not trusted:
|
||||
@@ -479,6 +601,8 @@ def cmd_upload(ws, filepath, message=None, dry_run=False):
|
||||
if not os.path.isfile(filepath):
|
||||
print("ERROR: file not found: %s" % filepath, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# React-readiness gate ("matching chromebox") - see cmd_send.
|
||||
assert_chat_ready(ws, context="upload")
|
||||
actions = check_approvals(ws)
|
||||
for dialog, trusted, action in actions:
|
||||
if not trusted:
|
||||
|
||||
Reference in New Issue
Block a user