feat: Python 版前置机 Agent 完整实现

- WebSocket 连接 B 端(注册/心跳/指数退避重连)
- 命令分发(10 种远程运维命令)
- 看门狗守护、日志实时流、在线升级含自动回滚
- 修复 main.py 日志格式 style 与占位符不匹配的问题
- 补充 README、配置示例、依赖清单

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:58:31 +08:00
co-authored by Claude
parent cc35a41ea4
commit 68751c2354
13 changed files with 1188 additions and 3 deletions
+141
View File
@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""下行命令分发器。
B 端通过 WebSocket 下发 JSON 命令:{"type":"CMD_TYPE","payload":{...}}
已支持命令:
- PING 探活(立即回 pong)
- GET_HEALTH 查询主程序健康
- GET_VERSION 查询主程序版本
- GET_LOG_FILES 日志文件列表
- LOG_TAIL 日志尾部(payload: file, lines
- LOG_TAIL_CTRL 日志实时流开关(payload: action, file
- RESTART 重启主程序(优雅停机后由 ProcessGuard 拉起)
- GET_CONFIG 读取医院配置文件(payload: hospital
- SAVE_CONFIG 写回医院配置文件(payload: hospital, content
- UPGRADE 升级主程序
所有命令执行结果通过 AgentClient 上行(CMD_RESULT)。
"""
import logging
import threading
import time
log = logging.getLogger(__name__)
class CommandDispatcher:
"""命令分发:解析 type → 执行 → 回执 CMD_RESULT(透传 cmdId)。"""
def __init__(self, config, main_app_client):
self.config = config
self.main_app_client = main_app_client
self._client = None # 由 main 注入(避免构造环)
self._upgrade_command = None # 升级命令(懒加载)
self._log_stream = None # 日志实时流管理(懒加载)
self._log_stream_lock = threading.Lock()
self._upgrade_lock = threading.Lock()
def set_client(self, client):
"""注入 AgentClient(main 组装阶段调用,避免构造环)。"""
self._client = client
def _get_upgrade_command(self):
"""懒加载升级命令。"""
with self._upgrade_lock:
if self._upgrade_command is None:
from agent.upgrade import UpgradeCommand
self._upgrade_command = UpgradeCommand(self.config, self.main_app_client, self._client)
return self._upgrade_command
def _get_log_stream(self):
"""懒加载日志流管理器。"""
with self._log_stream_lock:
if self._log_stream is None:
from agent.log_stream import LogStreamManager
self._log_stream = LogStreamManager(self.config, self.main_app_client, self._client)
return self._log_stream
def shutdown_log_stream(self):
"""日志实时流(供 main 停机时统一释放)。"""
self._get_log_stream().stop_all()
# ================= 分发 =================
def dispatch(self, msg_type, payload):
"""分发并执行命令。payload 为 dict 或 None。"""
log.info("收到命令:%s", msg_type)
payload = payload if isinstance(payload, dict) else None
if msg_type == "PING":
# 命令协议:B 端下发的 payload 内可带 cmdId,回执时透传以便并发匹配
result = {"pong": True, "ts": int(time.time())}
elif msg_type == "GET_HEALTH":
result = self.main_app_client.health()
elif msg_type == "GET_VERSION":
result = self.main_app_client.version()
elif msg_type == "GET_LOG_FILES":
result = {"ok": True, "data": self.main_app_client.log_files()}
elif msg_type == "LOG_TAIL":
file = (payload or {}).get("file") or "info.log"
lines = (payload or {}).get("lines") or 200
result = {"ok": True, "data": self.main_app_client.log_tail(file, lines)}
elif msg_type == "LOG_TAIL_CTRL":
action = (payload or {}).get("action") or "start"
file = (payload or {}).get("file") or "info.log"
result = self._get_log_stream().control(action, file)
elif msg_type == "RESTART":
ok = self.main_app_client.restart(1000)
result = {
"ok": ok,
"msg": "已触发优雅重启,等待 ProcessGuard 拉起" if ok else "重启请求失败",
}
elif msg_type == "GET_CONFIG":
hospital = (payload or {}).get("hospital") or ""
content = self.main_app_client.config_file(hospital)
result = {"ok": content is not None, "data": content}
elif msg_type == "SAVE_CONFIG":
hospital = (payload or {}).get("hospital") or ""
content = (payload or {}).get("content") or ""
result = {"ok": self.main_app_client.save_config_file(hospital, content)}
elif msg_type == "UPGRADE":
# 异步执行(升级耗时长,避免阻塞 WS 消息循环)
version = (payload or {}).get("version") or "unknown"
download_url = (payload or {}).get("downloadUrl")
sha256 = (payload or {}).get("sha256")
if not download_url or not download_url.strip():
result = {"ok": False, "msg": "缺少 downloadUrl"}
else:
threading.Thread(
target=self._run_upgrade,
args=(version, download_url, sha256),
name="upgrade",
daemon=True,
).start()
result = {"ok": True, "msg": "升级已开始(异步执行,结果将通过 UPGRADE_RESULT 上报)"}
else:
result = {"ok": False, "msg": f"未知命令:{msg_type}"}
envelope = {"cmd": msg_type}
# 透传 B 端下发的 cmdId(并发命令匹配用)
if payload is not None and payload.get("cmdId") is not None:
envelope["cmdId"] = payload.get("cmdId")
envelope["result"] = result
self._client.send("CMD_RESULT", envelope)
def _run_upgrade(self, version, download_url, sha256):
"""升级线程体:执行并上报 UPGRADE_RESULT。"""
r = self._get_upgrade_command().upgrade(version, download_url, sha256)
self._client.send("UPGRADE_RESULT", r)