r""" The bot's run log, read into the results page's live status (spec docs/superpowers/specs/2026-10-14-start-bot-from-web-page-design.md). Everything comes from lines the bot already prints, plus three fixed-format ones: `[CLAIMS] Queue: , , ...` (every claim of the run, before the first starts), `[CLAIMS] Results run_at: ` right after it (the run's time in the results table; only with RESULTS_DB=1), and one per claim, `[RESULT] claim n/N : `. The live log tail shows the log's text as it is, except the 2FA code (`[OTP] Code ...`) and the eCW username (`[CONFIG] user=...`), which are hidden. What is left can still hold patient data (claim numbers, form values) — the page shows it on 127.0.0.1 only, as it shows the results table. """ import re from datetime import datetime TAIL_LINES = 30 _QUEUE = re.compile(r"^\[CLAIMS\] Queue: (.*)$") _RUN_AT = re.compile(r"^\[CLAIMS\] Results run_at: " r"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)$") _CLAIM = re.compile(r"^\[CLAIMS\] --- claim (\d+)/(\d+): (\S+) ---$") _RESULT = re.compile(r"^\[RESULT\] claim (\d+)/(\d+) (\S+): (.+)$") # Hidden in the tail only. The OTP line loses everything after "Code": the # mail subject it quotes often repeats the code. _REDACT = ( (re.compile(r"(\[OTP\] Code )\S.*$"), r"\1(hidden)"), (re.compile(r"(\[CONFIG\] user=)\S*"), r"\1(hidden)"), ) def _redact(line: str) -> str: for pattern, repl in _REDACT: line = pattern.sub(repl, line) return line _PHASES = ( (("[LOGIN]", "[CAPTCHA]"), "Logging in"), (("[OTP]", "[2FA]"), "Waiting for the 2FA approval mail"), (("[OK] Logged in",), "Logged in"), (("[CLAIMS]", "[RESULT]"), "Checking claims"), ) _EXIT = { 0: "Finished", 1: "The run failed (see the log)", 2: "Stopped before login — a setting needs fixing, or another run is " "going (see the log)", 3: "Login guard refused the login — too many attempts this hour", 4: "eCW restricted the login — do not retry; check the account " "(see the log)", 5: "The claims step failed (see the log)", 6: "The 2FA approval mail did not arrive in time", 7: "Stopped: eCW changed a screen — a selector needs fixing (see the " "log's [PREFLIGHT] line)", 8: "Stopped: a claim stopped making progress (the page froze) — see " "the Runs page", } # A claim's status once the run is over (finished, stopped, crashed): # nothing is being checked or waiting any more. _STATUS_ONCE_OVER = {"Checking": "Not finished", "Waiting": "Not checked"} _PAGE_KEYS = ("running", "starting", "started_at", "ended_at", "exit_code", "stopped_by_operator", "source", "started_by") def parse(text: str) -> dict: """Phase, progress, counts, guard line, tail — and `claims`: every claim of the run as `{"n", "id", "status", "done"}`, status = its result (`done`), or "Checking" (started, no result yet), or "Waiting" (in the `[CLAIMS] Queue:` line, not started). A log with no Queue line lists the claims it has seen start. `results_run_at` is the run's time in the results table, None when the run saves none.""" phase, progress, current, guard = "Starting", None, None, None counts = {"Pass": 0, "Fail": 0, "Review": 0, "Not found": 0} queue, status, done, run_at = [], {}, set(), None lines = (text or "").splitlines() for raw in lines: line = raw.strip() for prefixes, name in _PHASES: if line.startswith(prefixes): phase = name m = _QUEUE.match(line) if m: queue = [c.strip() for c in m.group(1).split(",") if c.strip()] status, done, run_at = {}, set(), None m = _RUN_AT.match(line) if m: run_at = m.group(1) m = _CLAIM.match(line) if m: progress = [int(m.group(1)), int(m.group(2))] current = m.group(3) if current not in queue: queue.append(current) status[current] = "Checking" m = _RESULT.match(line) if m: cat = m.group(4).strip() counts[cat] = counts.get(cat, 0) + 1 status[m.group(3)] = cat done.add(m.group(3)) if line.startswith("[GUARD]"): guard = line claims = [{"n": n, "id": cid, "status": status.get(cid, "Waiting"), "done": cid in done} for n, cid in enumerate(queue, start=1)] return {"phase": phase, "progress": progress, "current": current, "counts": counts, "guard": guard, "claims": claims, "results_run_at": run_at, "tail": [_redact(ln.rstrip()) for ln in lines[-TAIL_LINES:]]} def exit_text(code, stopped: bool = False) -> str: if stopped: return "Stopped by operator" if code is None: return "Ended (no exit code — the run was stopped or crashed)" return _EXIT.get(code, f"Ended with code {code}") def read_tail(path, max_bytes: int | None = 256_000) -> str: """The end of a text file ('' when there is none); max_bytes=None reads the whole file.""" if not path: return "" try: with open(path, "rb") as fh: fh.seek(0, 2) size = fh.tell() fh.seek(0 if max_bytes is None else max(0, size - max_bytes)) text = fh.read().decode("utf-8", errors="replace") return text.replace("\r\n", "\n") except OSError: return "" def _elapsed(started, ended, running): """Seconds from start to end — to now while the run is going. A run that ended with no end time written (killed outside the page, a crash) has no duration: measured to now it grew on every poll (review, 2026-10-14).""" if not ended and not running: return None try: a = datetime.fromisoformat(started) b = datetime.fromisoformat(ended) if ended else datetime.now() return int((b - a).total_seconds()) except (TypeError, ValueError): return None def summarize(st: dict) -> dict: """The runner's status (web/bot_runner.py) plus what the log says. Only an allowlist of keys reaches the page (never pid or file paths). The whole log is read for counts/progress, so a long run is not undercounted; `tail` is still just the last TAIL_LINES lines.""" text = (read_tail(st.get("log_path"), None) or read_tail(st.get("out_path"), None)) p = parse(text) running = bool(st.get("running")) claims = p["claims"] if running: message = p["phase"] else: message = exit_text(st.get("exit_code"), st.get("stopped_by_operator", False)) if st.get("exit_code") == 3 and p["guard"]: message += " — " + p["guard"].removeprefix("[GUARD]").strip() claims = [dict(c, status=_STATUS_ONCE_OVER.get(c["status"], c["status"])) for c in claims] out = {k: st.get(k) for k in _PAGE_KEYS} out.update(phase=p["phase"], progress=p["progress"], current=p["current"], counts=p["counts"], tail=p["tail"], message=message, claims=claims, results_run_at=p["results_run_at"], elapsed_s=_elapsed(st.get("started_at"), st.get("ended_at"), running)) return out