#!/usr/bin/env python3 # -*- coding: utf-8 -*- """运维控制台的动作执行器 (2026-09-12) —— 跑一条命令, 并把**真实退出码**写回 run/ops_job.json。 为什么不让网关直接 Popen 就完事: 那样只知道"进程还在不在", 进程一结束就只能猜它成功没成功 —— 实测就这么吃过一次(停止服务脚本崩在 TypeError, 页面却显示"已停组件服务")。由本执行器包一层, 它在子进程结束后把 `status/rc/finished` 落盘, 页面就能显示"完成(退出码 0)"或"失败(退出码 1)"。 日志: 本进程的 stdout/stderr 已被调用方重定向到 logs/ops/_<时间戳>.log (统一日志口径), 子命令**继承**它, 所以命令自己的输出原样进日志(页面显示的就是这些尾巴)。 用法: python scripts/_ops_run.py --tag rebuild -- <命令与参数...> """ from __future__ import annotations import argparse import json import os import pathlib import subprocess import sys import threading import time ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from src import proc as _proc # 无窗口子进程 (2026-09-16 用户令: 不弹命令窗口) JOB = ROOT / 'run' / 'ops_job.json' HEARTBEAT_S = 15 def _write(j: dict): JOB.parent.mkdir(exist_ok=True) JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8') def _heartbeat(stop_evt: threading.Event): """每 15 s 更新 job 文件的 mtime —— 页面据此区分"还在跑"与"被强杀"(pid 会被复用, 只看 pid 会误判)。""" while not stop_evt.wait(HEARTBEAT_S): try: os.utime(JOB, None) except OSError: pass def main() -> int: ap = argparse.ArgumentParser() ap.add_argument('--tag', required=True) ap.add_argument('cmd', nargs=argparse.REMAINDER, help='-- 之后是真正的命令') a = ap.parse_args() cmd = [c for c in a.cmd if c != '--'] if not cmd: print('[X] 没给命令'); return 2 job = {} try: job = json.loads(JOB.read_text(encoding='utf-8')) except Exception: pass job.update(kind=a.tag, cmd=' '.join(cmd), pid=os.getpid(), status='running', started=job.get('started') or time.strftime('%Y-%m-%d %H:%M:%S')) _write(job) print(f'$ {" ".join([sys.executable] + cmd)}\n', flush=True) stop_evt = threading.Event() threading.Thread(target=_heartbeat, args=(stop_evt,), daemon=True).start() t0 = time.time() try: # 走 src.proc.run: 本进程是被无窗口起的 (没有可见控制台), 裸 spawn 会让 Windows # 给子进程**新建一个可见控制台窗口** —— 从页面点"重算"就是这个窗口在跑 20 分钟 (2026-09-16 实测)。 # ★stdout/stderr **显式**传本进程的句柄: CREATE_NO_WINDOW 会新建控制台并把标准句柄重指过去, # 不显式传的话, 子进程 (以及它的子进程) 的输出会掉进那个隐形控制台 —— 2026-09-16 实逮: # 页面上"重算中"但 logs/ops/ops_rebuild_*.log 里除了命令行一个字都没有。 rc = _proc.run([sys.executable] + cmd, cwd=str(ROOT), env=dict(os.environ), stdout=sys.stdout, stderr=sys.stderr).returncode except Exception as e: print(f'[X] 命令起不来: {type(e).__name__}: {e}', flush=True) rc = 99 stop_evt.set() dt = time.time() - t0 job.update(status='done', rc=rc, seconds=round(dt, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S')) _write(job) print(f'\n[ops] {a.tag} 结束: 退出码 {rc}, 耗时 {dt:.1f}s', flush=True) return rc if __name__ == '__main__': for _s in (sys.stdout, sys.stderr): try: _s.reconfigure(errors='replace') except Exception: pass sys.exit(main())