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:
ManikandanandClaude Opus 5.5 committed 2026-10-23 18:38:17 +05:30
commit ba15a574ab
205 files changed
+56772

No files matched your search

+209
View File
@@ -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}