Initial import: eCW Charge Validation bot and results Portal
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>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,209 @@
|
||||
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}
|
||||
Reference in new issue
Block a user