Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
9.0 KiB
Python
210 lines
9.0 KiB
Python
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 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}
|