Files
workphone-sdk/sdk/scripts/run_stability_longrun.py
2026-07-23 23:40:57 +08:00

272 lines
10 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""持续记录工作手机 SDK、WS 与 Hook 稳定性,供 24h/长期验收使用。"""
from __future__ import annotations
import argparse
import json
import os
import signal
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from urllib.request import Request, urlopen
def request_json(url: str, timeout: float, api_key: str = "") -> dict[str, Any]:
headers = {"Accept": "application/json", "User-Agent": "workphone-stability/1.1"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-API-Key"] = api_key
request = Request(url, headers=headers)
with urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def write_json(path: Path, data: dict[str, Any]) -> None:
temp = path.with_suffix(path.suffix + ".tmp")
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(path)
def restore_samples(samples_path: Path) -> tuple[datetime, int, int, int, int, int, int, int, bool | None, str]:
started_at = datetime.now().astimezone()
total = ok = failures = offline_events = reconnects = 0
latency_total = max_latency = 0
last_ok: bool | None = None
last_error = ""
if not samples_path.exists():
return (
started_at,
total,
ok,
failures,
offline_events,
reconnects,
latency_total,
max_latency,
last_ok,
last_error,
)
for line in samples_path.read_text(encoding="utf-8").splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(row, dict):
continue
try:
timestamp = datetime.fromisoformat(str(row.get("timestamp", "")))
if total == 0:
started_at = timestamp
except ValueError:
pass
row_ok = bool(row.get("ok"))
total += 1
ok += int(row_ok)
failures += int(not row_ok)
elapsed_ms = int(row.get("request_elapsed_ms") or 0)
latency_total += elapsed_ms
max_latency = max(max_latency, elapsed_ms)
if row_ok:
if last_ok is False:
reconnects += 1
last_error = ""
else:
if last_ok is not False:
offline_events += 1
last_error = str(row.get("error") or "sdk/ws/hook not ready")
last_ok = row_ok
return (
started_at,
total,
ok,
failures,
offline_events,
reconnects,
latency_total,
max_latency,
last_ok,
last_error,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="http://127.0.0.1:8899")
parser.add_argument("--device-id", default="xgfe65eimrrofyws")
parser.add_argument("--interval-seconds", type=float, default=60)
parser.add_argument("--timeout-seconds", type=float, default=45)
parser.add_argument("--require-hook", action="store_true", help="额外把Hook纳入成功门禁")
parser.add_argument("--output-dir", required=True)
args = parser.parse_args()
api_key = os.environ.get("WORKPHONE_API_KEY", "").strip()
output_dir = Path(args.output_dir).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
samples_path = output_dir / "stability_samples.jsonl"
summary_path = output_dir / "stability_latest_summary.json"
events_path = output_dir / "stability_recovery_events.jsonl"
stop = False
def handle_stop(_signum: int, _frame: Any) -> None:
nonlocal stop
stop = True
signal.signal(signal.SIGTERM, handle_stop)
signal.signal(signal.SIGINT, handle_stop)
(
started_at,
total,
ok,
failures,
offline_events,
reconnects,
latency_total,
max_latency,
last_ok,
last_error,
) = restore_samples(samples_path)
while not stop:
sample_started = time.time()
now = datetime.now().astimezone()
row: dict[str, Any] = {
"timestamp": now.isoformat(),
"device_id": args.device_id,
"ok": False,
}
try:
health = request_json(f"{args.base}/health", args.timeout_seconds, api_key)
probe: dict[str, Any] = {}
watch_error = ""
try:
query = urlencode({"device_id": args.device_id, "samples": 1, "interval_seconds": 0})
watch = request_json(
f"{args.base}/api/v3/stability/watch?{query}&include_hook={'true' if args.require_hook else 'false'}",
args.timeout_seconds,
api_key,
)
watch_data = watch.get("data") if isinstance(watch.get("data"), dict) else {}
samples = watch_data.get("samples") if isinstance(watch_data.get("samples"), list) else []
probe = samples[0] if samples and isinstance(samples[0], dict) else {}
except Exception as exc:
watch_error = str(exc)[:500]
device_ids = health.get("device_ids") if isinstance(health.get("device_ids"), list) else []
ws_online = bool(probe.get("ws_online")) if probe else args.device_id in device_ids
row.update(
{
"sdk_healthy": health.get("status") == "healthy",
"devices_online": health.get("devices_online"),
"ws_online": ws_online,
"adb_online": bool(probe.get("adb_online")),
"hook_ok": bool(probe.get("hook_ok")),
"hook_probe_included": bool(probe.get("hook_probe_included")),
"wechat_running": probe.get("wechat_running"),
"wechat_foreground": probe.get("wechat_foreground"),
"foreground_service": probe.get("foreground_service"),
"agent_running": probe.get("agent_running"),
"network_type": probe.get("network_type", ""),
"last_heartbeat": probe.get("last_heartbeat"),
"heartbeat_age_seconds": probe.get("heartbeat_age_seconds"),
"heartbeat_stale": probe.get("heartbeat_stale"),
"connect_stage": probe.get("connect_stage", ""),
"recovery_reason": probe.get("recovery_reason", ""),
"wechat_version": probe.get("wechat_version", ""),
"probe_latency_ms": probe.get("latency_ms", 0),
"probe_mode": "deep_watch" if probe else "health_fallback",
"watch_error": watch_error,
"error": probe.get("error", ""),
}
)
connection_ok = bool(
row["sdk_healthy"]
and row["ws_online"]
and not row.get("heartbeat_stale", False)
)
row["ok"] = bool(connection_ok and (not args.require_hook or row["hook_ok"]))
except Exception as exc:
row["error"] = str(exc)[:500]
row["request_elapsed_ms"] = int((time.time() - sample_started) * 1000)
total += 1
latency_total += row["request_elapsed_ms"]
max_latency = max(max_latency, row["request_elapsed_ms"])
if row["ok"]:
ok += 1
if last_ok is False:
reconnects += 1
last_error = ""
else:
failures += 1
last_error = str(row.get("error") or "sdk/ws/hook not ready")
if last_ok is not False:
offline_events += 1
last_ok = bool(row["ok"])
# 记录状态边沿,保留掉线开始、恢复和服务端/设备端原因便于24小时回读。
previous_ok = None
if samples_path.exists():
try:
previous_lines = samples_path.read_text(encoding="utf-8").splitlines()
if previous_lines:
previous_ok = bool(json.loads(previous_lines[-1]).get("ok"))
except (OSError, ValueError, json.JSONDecodeError):
previous_ok = None
if previous_ok != bool(row["ok"]):
event = {
"timestamp": row["timestamp"],
"device_id": args.device_id,
"event": "recovered" if row["ok"] else "offline_started",
"reason": row.get("error") or row.get("recovery_reason") or (
"connection_ok" if row["ok"] else "sdk/ws/heartbeat not ready"
),
"network_type": row.get("network_type", ""),
"connect_stage": row.get("connect_stage", ""),
"heartbeat_age_seconds": row.get("heartbeat_age_seconds"),
}
with events_path.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(event, ensure_ascii=False) + "\n")
with samples_path.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
elapsed_seconds = max((now - started_at).total_seconds(), 0)
summary = {
"started_at": started_at.isoformat(),
"updated_at": now.isoformat(),
"elapsed_seconds": int(elapsed_seconds),
"elapsed_hours": round(elapsed_seconds / 3600, 3),
"device_id": args.device_id,
"base": args.base,
"interval_seconds": args.interval_seconds,
"total_samples": total,
"ok_samples": ok,
"failed_samples": failures,
"success_rate": round(ok / total, 6) if total else 0,
"offline_events": offline_events,
"reconnects": reconnects,
"average_request_elapsed_ms": round(latency_total / total, 2) if total else 0,
"max_request_elapsed_ms": max_latency,
"last_ok": bool(row["ok"]),
"last_error": last_error,
"acceptance_24h_complete": elapsed_seconds >= 86400 and failures == 0,
"hook_required": args.require_hook,
"recovery_events_file": str(events_path),
"samples_file": str(samples_path),
}
write_json(summary_path, summary)
if not stop:
time.sleep(max(args.interval_seconds, 5))
return 0
if __name__ == "__main__":
raise SystemExit(main())