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,229 @@
|
||||
r"""
|
||||
The run record — `Logs\bot_run.json`, the latest bot run (spec
|
||||
docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md).
|
||||
|
||||
Written by the bot itself at the start of `main()` and finished when
|
||||
`main()` returns, so the results page sees a run started in the terminal
|
||||
as well as one it started. While a run is live a second `main()` refuses to
|
||||
start: two runs would share one eCW login guard and one Charge Summary.
|
||||
|
||||
{"pid", "started_at", "log_path", "source": "web" | "terminal",
|
||||
"ended_at", "exit_code", "stopped_by_operator"}
|
||||
|
||||
Live = no `ended_at`, the pid is a running python process, and that process
|
||||
was created no later than the record's `started_at` (plus a few seconds) —
|
||||
so a record left unended by a killed run, whose pid Windows later hands to
|
||||
another python process, does not read as live. A killed run cannot finish
|
||||
its own record; its pid is gone or reused, so it reads as ended.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .. import settings as cfg
|
||||
|
||||
FILE_NAME = "bot_run.json"
|
||||
|
||||
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
_STILL_ACTIVE = 259
|
||||
# started_at is written after the process exists, truncated to the second.
|
||||
_CREATED_SLACK = timedelta(seconds=5)
|
||||
_FILETIME_UNIX_EPOCH = 116444736000000000 # 1601-01-01 -> 1970-01-01, 100 ns
|
||||
|
||||
|
||||
class Busy(Exception):
|
||||
"""Another bot run is live."""
|
||||
|
||||
|
||||
def path():
|
||||
"""Read at call time: tests point cfg.LOGS elsewhere."""
|
||||
return cfg.LOGS / FILE_NAME
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def pid_alive(pid) -> bool:
|
||||
"""
|
||||
A running process with this pid whose program is python.
|
||||
|
||||
Never `os.kill(pid, 0)`: on Windows that TERMINATES the process.
|
||||
The program-name check keeps a stale record whose pid Windows gave to
|
||||
some other program from reading as a live bot run.
|
||||
"""
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid <= 0:
|
||||
return False
|
||||
if os.name != "nt":
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
k32.OpenProcess.restype = wintypes.HANDLE
|
||||
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE,
|
||||
ctypes.POINTER(wintypes.DWORD)]
|
||||
k32.QueryFullProcessImageNameW.argtypes = [
|
||||
wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR,
|
||||
ctypes.POINTER(wintypes.DWORD)]
|
||||
k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
code = wintypes.DWORD()
|
||||
if not k32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
||||
return False
|
||||
if code.value != _STILL_ACTIVE:
|
||||
return False
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
size = wintypes.DWORD(len(buf))
|
||||
if not k32.QueryFullProcessImageNameW(handle, 0, buf,
|
||||
ctypes.byref(size)):
|
||||
return True
|
||||
return "python" in os.path.basename(buf.value).lower()
|
||||
finally:
|
||||
k32.CloseHandle(handle)
|
||||
|
||||
|
||||
def process_created(pid):
|
||||
"""
|
||||
When process `pid` was created, as a local naive datetime (the same
|
||||
clock as the record's `started_at`); None when it cannot be read or
|
||||
the process is no longer running.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return None
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if pid <= 0:
|
||||
return None
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
k32.OpenProcess.restype = wintypes.HANDLE
|
||||
k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE,
|
||||
ctypes.POINTER(wintypes.DWORD)]
|
||||
k32.GetProcessTimes.argtypes = (
|
||||
[wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4)
|
||||
k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
handle = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return None
|
||||
try:
|
||||
# An ended process can still be opened while some handle to it is
|
||||
# held (its parent's, say) and still has times: not a running one.
|
||||
code = wintypes.DWORD()
|
||||
if (not k32.GetExitCodeProcess(handle, ctypes.byref(code))
|
||||
or code.value != _STILL_ACTIVE):
|
||||
return None
|
||||
created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4))
|
||||
if not k32.GetProcessTimes(handle, ctypes.byref(created),
|
||||
ctypes.byref(exited), ctypes.byref(kernel),
|
||||
ctypes.byref(user)):
|
||||
return None
|
||||
ticks = (created.dwHighDateTime << 32) | created.dwLowDateTime
|
||||
if ticks <= _FILETIME_UNIX_EPOCH:
|
||||
return None
|
||||
return datetime.fromtimestamp(
|
||||
(ticks - _FILETIME_UNIX_EPOCH) / 10_000_000)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
finally:
|
||||
k32.CloseHandle(handle)
|
||||
|
||||
|
||||
def read():
|
||||
"""The record, or None when there is none or it cannot be read."""
|
||||
try:
|
||||
data = json.loads(path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def is_live(rec) -> bool:
|
||||
if not rec or rec.get("ended_at") or not pid_alive(rec.get("pid")):
|
||||
return False
|
||||
# Same pid, but a younger process: the pid was reused after the run
|
||||
# died without finishing its record. If either time cannot be read,
|
||||
# trust the pid.
|
||||
try:
|
||||
started = datetime.fromisoformat(rec["started_at"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return True
|
||||
created = process_created(rec.get("pid"))
|
||||
try:
|
||||
if created is not None and created > started + _CREATED_SLACK:
|
||||
return False
|
||||
except TypeError:
|
||||
pass # started_at carries a UTC offset: keep the pid verdict
|
||||
return True
|
||||
|
||||
|
||||
def _write(rec: dict) -> None:
|
||||
target = path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(rec, indent=2), encoding="utf-8")
|
||||
# On Windows the replace is refused for a moment while another process
|
||||
# (the page polling, a scanner) holds the file open.
|
||||
for attempt in range(10):
|
||||
try:
|
||||
os.replace(tmp, target)
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt == 9:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def acquire(log_path, source=None) -> dict:
|
||||
"""This process's record, written; raises Busy over a live one."""
|
||||
rec = read()
|
||||
if is_live(rec):
|
||||
raise Busy(f"A bot run is already going (pid {rec.get('pid')}, "
|
||||
f"started {rec.get('started_at') or '?'}) — not starting "
|
||||
f"a second one")
|
||||
rec = {"pid": os.getpid(), "started_at": _now(),
|
||||
"log_path": str(log_path),
|
||||
"source": source or os.environ.get("ECW_RUN_SOURCE") or "terminal",
|
||||
# The Portal User for a Portal run (EEWA-32), else Windows.
|
||||
"started_by": (os.environ.get("ECW_STARTED_BY")
|
||||
or os.environ.get("USERNAME") or ""),
|
||||
"ended_at": None, "exit_code": None,
|
||||
"stopped_by_operator": False}
|
||||
_write(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def finish(exit_code) -> None:
|
||||
"""End this process's own record. Never raises."""
|
||||
try:
|
||||
rec = read()
|
||||
if not rec or rec.get("pid") != os.getpid():
|
||||
return
|
||||
rec.update(ended_at=_now(), exit_code=exit_code)
|
||||
_write(rec)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def mark_stopped(pid) -> None:
|
||||
"""The page killed run `pid`: say so on its record."""
|
||||
rec = read()
|
||||
if not rec or rec.get("pid") != pid or rec.get("ended_at"):
|
||||
return
|
||||
rec.update(ended_at=_now(), exit_code=None, stopped_by_operator=True)
|
||||
_write(rec)
|
||||
Reference in new issue
Block a user