"""Runs the bot in its own process and turns its log file into Run tab events. No Qt in here (ADR-0002): the bot runs as a separate process with no Qt, writes every line to a log file, and this module reads that file every 0.2 s. It does not read a stdout pipe, because a windowed PyInstaller child's pipe is block-buffered and lines arrive late. Bot lines it understands: [CPSTATE] active N / [CPSTATE] done N checkpoint progress (ADR-0001) [UI] text, [UI:ok] / [UI:warn] / [UI:error] text lines the operator sees Error / exception lines always shown, even untagged __RUN_SUMMARY__{"total":n,"ok":n,"failed":[[item, reason], ...]} the outcome Everything else stays in the log file only. """ import json import re import subprocess import sys import threading import time SUMMARY = "__RUN_SUMMARY__" _CP = re.compile(r"^\[CPSTATE\]\s+(active|done)\s+(\d+)") _UI = re.compile(r"^\[UI(?::(ok|warn|error))?\]\s?(.*)$") # Untagged lines that still reach the screen: the last line of a traceback # ("ValueError: ...") and lines starting with ERROR. # ponytail: a regex guess, so add a [UI:error] line to the bot when an error shows badly. _ERR = re.compile(r"^([A-Za-z_][\w.]*(Error|Exception)\b|ERROR\b)") _UI_TYPE = {None: "info", "ok": "success", "warn": "warn", "error": "error"} _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) def parse(line): """One bot line -> (event, data), or None when it stays off screen.""" m = _CP.match(line) if m: return "cpstate", {"kind": m[1], "cp": int(m[2])} m = _UI.match(line) if m: return "log", {"msg": m[2], "type": _UI_TYPE[m[1]]} if _ERR.match(line): return "log", {"msg": line, "type": "error"} return None def outcome(summary, stuck_name=None): """(state, message) for the page: done (green), partial (amber) or error (red).""" if stuck_name: return "error", f"Stuck at {stuck_name}, run again" if summary is None: return "error", "Failed: the bot stopped without a summary (see the run log)" total = int(summary.get("total") or 0) failed = summary.get("failed") or [] if failed and len(failed) >= total: return "error", f"Failed: all {len(failed)} item(s) failed" if failed: return "partial", f"Finished: {len(failed)} of {total} failed" return "done", f"Completed: {total} item(s)" if total else "Completed" def kill_tree(proc, kill_also=()): """Kill the bot and everything it started, then any extra .exe names (e.g. a driver).""" if proc is not None and proc.poll() is None: if sys.platform.startswith("win"): subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=15, creationflags=_NO_WINDOW) else: proc.kill() # ponytail: direct child only off Windows; use a process group if bots spawn children there for image in kill_also: subprocess.run(["taskkill", "/F", "/IM", image], capture_output=True, timeout=10, creationflags=_NO_WINDOW) class Run: """One bot run. emit(event, data) is called from the run thread.""" def __init__(self, cmd, log_path, checkpoints, emit, kill_also=(), cwd=None): self.cmd, self.log_path, self.emit = cmd, str(log_path), emit self.checkpoints = checkpoints # [(name, max_seconds)], 0 = no limit self.kill_also, self.cwd = kill_also, cwd self.proc = None self.summary = None self.active, self.since, self.stuck = 0, 0.0, None self.thread = threading.Thread(target=self._run, daemon=True) def start(self): self.thread.start() return self def kill(self): kill_tree(self.proc, self.kill_also) def _line(self, line): if line.startswith(SUMMARY): try: self.summary = json.loads(line[len(SUMMARY):]) except ValueError: self.emit("log", {"msg": "Could not read the bot's summary line", "type": "warn"}) return ev = parse(line) if not ev: return if ev[0] == "cpstate": kind, n = ev[1]["kind"], ev[1]["cp"] if kind == "active": self.active, self.since = n, time.monotonic() elif n == self.active: self.active = 0 self.emit(*ev) def _check_stuck(self): if not self.active or self.active > len(self.checkpoints): return False name, limit = self.checkpoints[self.active - 1] if limit and time.monotonic() - self.since > limit: self.stuck = name self.emit("cpstate", {"kind": "stuck", "cp": self.active}) self.emit("log", {"msg": f"{name} took longer than {limit}s, stopping the run", "type": "error"}) self.kill() return True return False def _run(self): self.emit("status", {"state": "running", "msg": "Running automation..."}) open(self.log_path, "w", encoding="utf-8").close() try: self.proc = subprocess.Popen(self.cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=self.cwd, creationflags=_NO_WINDOW) except OSError as e: self.emit("status", {"state": "error", "msg": f"Could not start the bot: {e}"}) return buf = "" with open(self.log_path, encoding="utf-8", errors="replace") as f: while True: exited = self.proc.poll() is not None # check before reading, so the last lines are never lost chunk = f.read() if chunk: buf += chunk *lines, buf = buf.split("\n") for ln in lines: self._line(ln.rstrip("\r")) elif exited or self.stuck: break elif self._check_stuck(): self.proc.wait(timeout=30) else: time.sleep(0.2) if buf.strip(): self._line(buf.rstrip("\r")) for item, reason in (self.summary or {}).get("failed") or []: self.emit("log", {"msg": f"✕ {item}: {reason}", "type": "error"}) state, msg = outcome(None if self.stuck else self.summary, self.stuck) self.emit("status", {"state": state, "msg": msg})