r""" Start, watch and stop the bot from the results page (spec docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md). The bot runs as its own process — `python run.py`, the same entry point as the terminal — so a hung or crashed run never takes the web server with it, and closing the page does not stop the run. The command and its environment are fixed here; nothing from a request reaches them. The run itself says whether it is live: `Logs\bot_run.json` (`core/run_lock.py`), which the bot writes at start and finishes at the end, terminal runs included. """ import os import sys import pathlib import time import threading import subprocess from datetime import datetime, timedelta from ecw_bot import settings as cfg from ecw_bot.core import run_lock, run_events ROOT = pathlib.Path(__file__).resolve().parent.parent # A page-started run: VERIFY whatever .env says, no "Press Enter", no # information boxes (the page shows the run), no "Run the Lookup?" box. FORCED_ENV = { "SAVE_CORRECTIONS": "0", "ECW_KEEP_OPEN": "0", "QUIET_BOXES": "1", "CONFIRM_BEFORE_LOOKUP": "0", "ECW_RUN_SOURCE": "web", } _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) class AlreadyRunning(Exception): pass class NotRunning(Exception): pass class StopFailed(Exception): pass class NoRunner: """The Portal on a server (Dokploy, 2026-10-27): the bot runs only on the Windows PC, so nothing here ever starts, stops or looks for it.""" def start(self, started_by: str = "") -> dict: raise NotRunning("Runs start on the bot PC, not from this server") def stop(self, stopped_by: str = "") -> dict: raise NotRunning("Runs start on the bot PC, not from this server") def status(self) -> dict: return {"running": False, "starting": False, "pid": None, "started_at": None, "ended_at": None, "exit_code": None, "stopped_by_operator": False, "source": None, "started_by": None, "log_path": None, "out_path": None} class BotRunner: # How long stop() waits for a killed process to be gone. stop_wait = 10.0 def __init__(self, command=None, root=ROOT): self.command = list(command or [sys.executable, str(root / "run.py")]) self.root = pathlib.Path(root) self._proc = None self._out_path = None self._spawned_at = None self._stopped_child = False self._started_by = "" # The page's requests run on a thread pool: a double-clicked Start # must not pass the checks twice and spawn two children. self._lock = threading.Lock() def _child_running(self) -> bool: return self._proc is not None and self._proc.poll() is None def _kill(self, pid) -> None: # /T: the bot's Chrome goes with it. subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, creationflags=_NO_WINDOW) def start(self, started_by: str = "") -> dict: with self._lock: rec = run_lock.read() if run_lock.is_live(rec): raise AlreadyRunning( f"A run is already going (started " f"{(rec or {}).get('started_at') or '?'})") if self._child_running(): raise AlreadyRunning("A run is already going (still starting)") # Without the keys this server took from .env: the child reads # .env afresh, so an edit made since the server started is seen. env = {k: v for k, v in os.environ.items() if k not in cfg.ENV_FILE_KEYS} # Except the results database: the run writes its rows where # this page reads them, even if .env names another since. env["RESULTS_DB_CONN"] = cfg.RESULTS_DB_CONN env.update(FORCED_ENV) # Started By on the Runs page: the signed-in Portal User. Only # a username the Portal itself checked ever gets here. env["ECW_STARTED_BY"] = started_by self._started_by = started_by cfg.LOGS.mkdir(parents=True, exist_ok=True) self._out_path = (cfg.LOGS / f"web_run_{datetime.now():%Y%m%d_%H%M%S}.out") self._stopped_child = False self._spawned_at = datetime.now() with open(self._out_path, "ab") as out: self._proc = subprocess.Popen( self.command, cwd=str(self.root), env=env, stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, creationflags=_NO_WINDOW) print(f"[WEB] Bot run started from the page (pid {self._proc.pid})") return self.status() def stop(self, stopped_by: str = "") -> dict: with self._lock: rec = run_lock.read() live = run_lock.is_live(rec) own_child = (live and self._proc is not None and int(rec["pid"]) == self._proc.pid) # A run started in the terminal may be LIVE-WRITE, and the page # says VERIFY: it is stopped where it was started. if live and rec.get("source") != "web" and not own_child: raise NotRunning( "This run was started in the terminal — stop it there") pids = [] if live and int(rec["pid"]) != os.getpid(): pids.append(int(rec["pid"])) if self._child_running(): pids.append(self._proc.pid) pids = list(dict.fromkeys(pids)) if not pids: raise NotRunning("No run is going") for pid in pids: self._kill(pid) gone, alive = [], list(pids) end = time.monotonic() + self.stop_wait while alive: alive = [p for p in alive if run_lock.pid_alive(p)] if not alive or time.monotonic() >= end: break time.sleep(0.2) gone = [p for p in pids if p not in alive] if self._proc is not None and self._proc.pid in gone: self._stopped_child = True # mark_stopped() re-reads the record: a child that was still # starting may have written it between the first read and the # kill. for pid in gone: run_lock.mark_stopped(pid) if rec and rec.get("log_path") and int(rec["pid"]) in gone: # The killed bot cannot write its own end: the Runs page # reads this one, with who pressed Stop. run_events.append_to(rec["log_path"], "stopped", stopped_by=stopped_by) if gone: print(f"[WEB] Bot run stopped from the page (pid " f"{', '.join(str(p) for p in gone)})") if alive: raise StopFailed( f"Could not stop the run (pid " f"{', '.join(str(p) for p in alive)} is still running) " f"— close it from the bot's own window or Task Manager") return self.status() def _record_predates_child(self, rec) -> bool: """No record, or one started before this runner's child was spawned (1 s slack: started_at is cut to the second). An unreadable time counts as older.""" if not rec: return True try: started = datetime.fromisoformat(rec["started_at"]) return started < self._spawned_at - timedelta(seconds=1) except (KeyError, TypeError, ValueError): return True def status(self) -> dict: rec = run_lock.read() or {} ours = self._proc is not None and rec.get("pid") == self._proc.pid # A record that is not the child's but newer than it (a terminal # run after the page's) is shown as itself. if (self._proc is not None and not ours and self._record_predates_child(rec)): # The child has not written its record yet — or ended before # it could (a crash at import, a refused second run). if self._child_running(): return {"running": True, "starting": True, "pid": None, "started_at": None, "ended_at": None, "exit_code": None, "stopped_by_operator": False, "source": "web", "started_by": self._started_by, "log_path": None, "out_path": str(self._out_path)} if not run_lock.is_live(rec): return {"running": False, "starting": False, "pid": None, "started_at": None, "ended_at": None, "exit_code": (None if self._stopped_child else self._proc.returncode), "stopped_by_operator": self._stopped_child, "source": "web", "started_by": self._started_by, "log_path": None, "out_path": str(self._out_path)} exit_code = rec.get("exit_code") if (ours and exit_code is None and not self._child_running() and not rec.get("stopped_by_operator")): exit_code = self._proc.returncode return {"running": run_lock.is_live(rec), "starting": False, "pid": rec.get("pid"), "started_at": rec.get("started_at"), "ended_at": rec.get("ended_at"), "exit_code": exit_code, "stopped_by_operator": bool(rec.get("stopped_by_operator")), "source": rec.get("source"), "started_by": rec.get("started_by") or "", "log_path": rec.get("log_path"), "out_path": str(self._out_path) if ours and self._out_path else None}