From 9ed90c7ca82f2ae33e7f994aa686cf6afda20e06 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 05:42:43 +0200 Subject: [PATCH] feat: support Linux headless HWPOD node --- tools/hwlab-node.py | 57 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index 562a4cb9..b8437e07 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # SPEC: PJ2026-010104 AI网关; PJ2026-010405 云端控制台。 # Implementation reference: draft-2026-07-13-p0-cloud-console. -"""面向 Windows HWPOD 的单文件 HWLAB 图形节点。 +"""跨 Windows GUI 与 Linux headless 的单文件 HWLAB HWPOD 节点。 用户入口不接受命令行参数,也不依赖 HWLAB_* 环境变量。 持久配置统一存放在 ~/.hwlab/,并由设置界面或受控部署管理。 @@ -24,6 +24,7 @@ import queue import random import re import shutil +import signal import socket import ssl import struct @@ -54,15 +55,16 @@ except Exception: # pragma: no cover - import failure is reported in main. APP_VERSION = "0.1.3" -NODE_VERSION = f"{APP_VERSION}-python-gui" +NODE_VERSION = f"{APP_VERSION}-python" CONTRACT_VERSION = "hwpod-node-ops-v1" APP_NAME = "HWLAB Node" CONFIG_DIR = Path.home() / ".hwlab" LOG_DIR = CONFIG_DIR / "logs" +STATE_DIR = CONFIG_DIR / "state" UPDATE_DIR = CONFIG_DIR / "update" CONFIG_PATH = CONFIG_DIR / "config.json" -STATE_PATH = CONFIG_DIR / "state.json" -OPERATION_LEDGER_PATH = CONFIG_DIR / "hwpod-operations.json" +STATE_PATH = STATE_DIR / "node.json" +OPERATION_LEDGER_PATH = STATE_DIR / "hwpod-operations.json" LOG_PATH = LOG_DIR / "hwlab-node.log" DEFAULT_CONFIG = { @@ -101,6 +103,7 @@ SECRET_PATTERNS = [ def ensure_dirs() -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) LOG_DIR.mkdir(parents=True, exist_ok=True) + STATE_DIR.mkdir(parents=True, exist_ok=True) UPDATE_DIR.mkdir(parents=True, exist_ok=True) @@ -1003,9 +1006,9 @@ class WebSocketNodeClient: "capabilities": self.executor.capabilities(), "maxInFlight": 1, "runtime": { - "kind": "python-tkinter-gui", + "kind": "python-tkinter-gui" if sys.platform == "win32" else "python-headless", "installed": True, - "desktopVisible": True, + "desktopVisible": sys.platform == "win32", "currentVersion": APP_VERSION, "updateChannel": str(self.config.get("update", {}).get("channel") or "stable"), }, @@ -1807,12 +1810,52 @@ class HwlabNodeApp: self.root.mainloop() +class HeadlessHwlabNodeApp: + def __init__(self): + ensure_dirs() + self.store = ConfigStore() + self.config = self.store.config + self.logger = setup_logger() + self.bus = UiEventBus() + install_exception_hooks(self.logger, self.bus) + self.executor = NodeOpsExecutor(self.config, self.logger) + self.client = WebSocketNodeClient(self.config, self.executor, self.logger, self.bus) + self.stop_event = threading.Event() + + def stop(self, *_args) -> None: + self.stop_event.set() + self.client.stop() + + def run(self) -> None: + if self.config.get("autoConnect") is not True: + raise RuntimeError("Linux headless mode requires autoConnect=true") + for name in ("SIGINT", "SIGTERM"): + signum = getattr(signal, name, None) + if signum is not None: + signal.signal(signum, self.stop) + self.logger.info("headless node starting: nodeId=%s config=%s", self.executor.node_id, CONFIG_PATH) + self.client.start() + while not self.stop_event.wait(0.25): + while True: + try: + kind, payload = self.bus.queue.get_nowait() + except queue.Empty: + break + if kind == "log": + self.logger.info("%s", redact(payload)) + elif kind == "connection": + self.logger.info("connection state: %s", redact(payload)) + if self.client.thread and self.client.thread.is_alive(): + self.client.thread.join(timeout=5) + atomic_write_json(STATE_PATH, {"lastExitAt": utc_now(), "connectionState": "stopped", "runtimeKind": "python-headless"}) + + def main() -> int: ensure_dirs() logger = setup_logger() install_exception_hooks(logger) try: - app = HwlabNodeApp() + app = HwlabNodeApp() if sys.platform == "win32" else HeadlessHwlabNodeApp() app.run() return 0 except Exception as exc: