feat: support Linux headless HWPOD node

This commit is contained in:
root
2026-07-23 05:42:43 +02:00
parent d8f01ee47f
commit 9ed90c7ca8
+50 -7
View File
@@ -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: