The results Portal in a Linux container (python 3.13-slim + ODBC Driver 18). PORTAL_SERVER=1: no Start / Stop (the bot runs on the Windows PC). PORTAL_URLS: allowed hosts / origins, Secure cookie when all https. RESULTS_DB_CONN (a SQL login) goes in Dokploy's environment, never here. 71 test files, 1222 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.2 KiB
Python
233 lines
8.2 KiB
Python
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
|
|
try:
|
|
from ctypes import wintypes
|
|
except ValueError: # some non-Windows Pythons; the server Portal
|
|
wintypes = None # (PORTAL_SERVER=1) never calls the kernel32 parts
|
|
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)
|