r""" The Runs page's data: every Run's `Logs\events_.jsonl` (written by `ecw_bot.core.run_events`) read into a summary for the list and a detail for one Run (spec docs/superpowers/specs/2026-10-22-run-tracking-and-portal- sign-in-design.md, Stage 1; terms in GLOSSARY.md). Only Runs with an events file are listed: the run logs from before 2026-10-22 have no times on their lines and are not imported. A Run's result: * **Done** — `run_end` with exit 0; * **Run Error** — `run_end` with any other exit (its Run Error's step and message alongside); * **Stopped** — the Portal's `stopped` event, written after its Stop; * **Running** — no end yet and its process is the live one; * **Ended without a record** — no end and not live: killed some other way. Everything here is read-only and never raises on a bad file: a line a killed Run left half-written is skipped. """ import re import json from datetime import datetime, date from pathlib import Path RUN_ID = re.compile(r"^\d{8}_\d{6}$") STATUSES = ("Running", "Done", "Run Error", "Stopped", "Ended without a record") LOGIN_STEPS = ("page_open", "password", "captcha_shown", "captcha_solved", "captcha_failed", "twofa_requested", "mail_received", "approval_clicked", "home") STEP_LABELS = { "page_open": "Login page opened", "password": "Password typed", "captcha_shown": "Captcha shown", "captcha_solved": "Captcha solved", "captcha_failed": "Captcha failed", "twofa_requested": "Log In clicked (2FA mail requested)", "mail_received": "2FA mail received", "approval_clicked": "Approval clicked", "home": "Home page reached", } LOGIN_RESULTS = {"ok": "OK", "twofa_timeout": "2FA timeout", "captcha_failed": "Captcha failed", "restricted": "Restricted", "failed": "Failed"} def valid_run_id(run_id: str) -> bool: return bool(RUN_ID.match(run_id or "")) def _time(text): try: return datetime.fromisoformat(text) except (TypeError, ValueError): return None def _secs(a, b): return int((b - a).total_seconds()) if a and b else None def read_events(logs_dir, run_id) -> list: """A Run's events in file order; [] for no file. Never raises.""" if not valid_run_id(run_id): return [] path = Path(logs_dir) / f"events_{run_id}.jsonl" try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] return _parse(text.splitlines()) def _parse(lines) -> list: """Event dicts from JSON lines, a bad or untimed line skipped.""" out = [] for line in lines: try: e = json.loads(line) except ValueError: continue # half-written by a killed Run if isinstance(e, dict) and _time(e.get("t")): e["_t"] = _time(e["t"]) out.append(e) return out def _text(e) -> str: """One timeline line.""" k = e.get("kind") if k == "run_start": return (f"Run started by {e.get('started_by') or '?'} " f"({e.get('source')}, {e.get('mode')})") if k == "disk": return f"Disk {e.get('drive')}: {e.get('free_mb')} MB free" if k == "login_attempt_start": return f"eCW Login Attempt {e.get('attempt')} started" if k == "login_step": return (f"Attempt {e.get('attempt')}: " f"{STEP_LABELS.get(e.get('step'), e.get('step'))}") if k == "login_attempt_end": msg = f" — {e['message']}" if e.get("message") else "" return (f"eCW Login Attempt {e.get('attempt')}: " f"{LOGIN_RESULTS.get(e.get('result'), e.get('result'))}{msg}") if k == "queue": return f"{len(e.get('claims') or [])} claim(s) queued" if k == "claim_start": return f"Claim {e.get('n')}/{e.get('total')} {e.get('claim')} started" if k == "claim_result": return f"Claim {e.get('claim')}: {e.get('category')}" if k == "claim_error": return (f"Claim Error {e.get('claim')} ({e.get('step')}): " f"{e.get('message')}") if k == "warning": who = f" {e['claim']}" if e.get("claim") else "" return f"Warning{who} ({e.get('code')}): {e.get('message')}" if k == "screenshot": return f"Screenshot: {Path(e.get('path') or '').name}" if k == "run_error": return (f"Run Error ({e.get('step')}, exit {e.get('exit_code')}): " f"{e.get('message')}") if k == "run_end": return f"Run ended, exit {e.get('exit_code')}" if k == "stopped": return f"Stopped from the Portal by {e.get('stopped_by') or '?'}" return k or "?" def _attempts(events) -> list: by_n = {} for e in events: k = e.get("kind") if k not in ("login_attempt_start", "login_step", "login_attempt_end"): continue a = by_n.setdefault(e.get("attempt"), { "attempt": e.get("attempt"), "start": None, "end": None, "result": None, "result_label": "Did not finish", "message": "", "steps": {}, "_steps": {}}) if k == "login_attempt_start": a["start"] = e["_t"] elif k == "login_step": a["_steps"].setdefault(e.get("step"), e["_t"]) a["steps"].setdefault(e.get("step"), e["t"]) else: a["end"] = e["_t"] a["result"] = e.get("result") a["result_label"] = LOGIN_RESULTS.get(e.get("result"), e.get("result")) a["message"] = e.get("message") or "" out = [] for a in sorted(by_n.values(), key=lambda a: a["attempt"] or 0): a["duration_s"] = _secs(a["start"], a["end"]) a["twofa_wait_s"] = _secs(a["_steps"].get("twofa_requested"), a["_steps"].get("mail_received")) a["step_rows"] = [(STEP_LABELS[s], a["steps"][s][11:19]) for s in LOGIN_STEPS if s in a["steps"]] del a["_steps"] out.append(a) return out def summarize(run_id, events, live_run_id=None, now=None) -> dict: now = now or datetime.now() first = events[0] if events else {} start_ev = next((e for e in events if e["kind"] == "run_start"), {}) end_ev = next((e for e in events if e["kind"] == "run_end"), None) stop_ev = next((e for e in events if e["kind"] == "stopped"), None) run_errors = [e for e in events if e["kind"] == "run_error"] started = start_ev.get("_t") or first.get("_t") or _time_of_id(run_id) if end_ev is not None: code = end_ev.get("exit_code") status = "Done" if code == 0 else "Run Error" ended = end_ev["_t"] elif stop_ev is not None: code, status, ended = None, "Stopped", stop_ev["_t"] elif run_id == live_run_id: code, status, ended = None, "Running", None else: code, status = None, "Ended without a record" ended = events[-1]["_t"] if events else None results = [e for e in events if e["kind"] == "claim_result"] queue = next((e.get("claims") or [] for e in events if e["kind"] == "queue"), []) started_claims = {e.get("claim") for e in events if e["kind"] == "claim_start"} not_reached = ([c for c in queue if c not in started_claims] if status != "Running" else []) attempts = _attempts(events) err = run_errors[0] if run_errors else {} return { "run_id": run_id, "started": started, "ended": ended, "duration_s": _secs(started, ended or now), "started_by": start_ev.get("started_by") or "", "source": start_ev.get("source") or "", "mode": start_ev.get("mode") or "", "test_sheet": start_ev.get("test_sheet") or "", "status": status, "exit_code": code, "error_step": err.get("step") or "", "error_message": err.get("message") or "", "stopped_by": (stop_ev or {}).get("stopped_by") or "", "login_attempts": attempts, "login_total_s": sum(a["duration_s"] or 0 for a in attempts), "pass": sum(e.get("category") == "Pass" for e in results), "fail": sum(e.get("category") == "Fail" for e in results), "review": sum(e.get("category") == "Review" for e in results), "not_found": sum(e.get("category") == "Not found" for e in results), "claims_total": len(queue), "claims_done": len(results), "run_errors": run_errors, "claim_errors": [e for e in events if e["kind"] == "claim_error"], "warnings": [e for e in events if e["kind"] == "warning"], "screenshots": [e for e in events if e["kind"] == "screenshot"], "not_reached": not_reached, "timeline": [{"t": e["t"], "time": e["t"][11:19], "kind": e["kind"], "text": _text(e)} for e in events], } def _time_of_id(run_id): try: return datetime.strptime(run_id, "%Y%m%d_%H%M%S") except (TypeError, ValueError): return None class FileEvents: """Runs from this PC's `Logs/events_.jsonl` files.""" def __init__(self, logs_dir): self.dir = Path(logs_dir) def run_ids(self) -> list: try: files = sorted(self.dir.glob("events_*.jsonl"), reverse=True) except OSError: return [] return [p.stem[len("events_"):] for p in files if valid_run_id(p.stem[len("events_"):])] def events(self, run_ids) -> dict: return {r: read_events(self.dir, r) for r in run_ids if (self.dir / f"events_{r}.jsonl").is_file()} class DbEvents: """Runs from `dbo.CV_RunEvent` (2026-10-30): the Portal on Dokploy, which cannot read the bot PC's Logs folder. One query per page.""" def __init__(self, repo): self.repo = repo def run_ids(self) -> list: return [r for r in self.repo.run_ids() if valid_run_id(r)] def events(self, run_ids) -> dict: run_ids = list(run_ids) if not run_ids: return {} lines = {} for run_id, body in self.repo.run_event_lines(run_ids): lines.setdefault(run_id, []).append(body) return {r: _parse(lines[r]) for r in run_ids if r in lines} def _source(src): """A path (the old way, and the tests) or an events source.""" return src if hasattr(src, "run_ids") else FileEvents(src) def load_run(logs_dir, run_id, live_run_id=None, now=None): """One Run's detail, or None (bad id, no events).""" if not valid_run_id(run_id): return None found = _source(logs_dir).events([run_id]) if run_id not in found: return None return summarize(run_id, found[run_id], live_run_id, now) def list_runs(logs_dir, live_run_id=None, now=None, day_from=None, day_to=None, status=None) -> list: """Every Run with events, newest first, filtered. `day_from` / `day_to` are ISO dates (inclusive), `status` one of STATUSES.""" d_from = date.fromisoformat(day_from) if day_from else None d_to = date.fromisoformat(day_to) if day_to else None src = _source(logs_dir) ids = [] for run_id in src.run_ids(): day = _time_of_id(run_id).date() if (d_from and day < d_from) or (d_to and day > d_to): continue ids.append(run_id) found = src.events(ids) out = [] for run_id in ids: if run_id not in found: continue r = summarize(run_id, found[run_id], live_run_id, now) if status and r["status"] != status: continue out.append(r) return out def latest_run(logs_dir, live_run_id=None, now=None): """The newest Run's summary, or None — reads one Run, not all.""" src = _source(logs_dir) for run_id in src.run_ids(): found = src.events([run_id]) if run_id in found: return summarize(run_id, found[run_id], live_run_id, now) return None def screenshot_path(logs_dir, run_id, n, screenshots_root): """The n-th screenshot event's .png, if it is inside `screenshots_root` and exists; else None. Only paths a Run itself recorded are served.""" if not valid_run_id(run_id): return None events = _source(logs_dir).events([run_id]).get(run_id, []) shots = [e for e in events if e["kind"] == "screenshot"] if not 0 <= n < len(shots): return None try: root = Path(screenshots_root).resolve() path = Path(shots[n].get("path") or "").resolve() except (OSError, ValueError): return None # PNG only: a saved page's HTML served from the Portal's own origin # would run eCW's scripts there. if (root not in path.parents or path.suffix.lower() != ".png" or not path.is_file()): return None return path LIVE_STALE_S = 600 def live_status(source, now=None) -> dict: """The Dashboard's Agent run card on a server Portal (2026-10-30): the newest Run in the events source, in `/api/bot/status`'s shape. Running = no end yet and an event in the last LIVE_STALE_S seconds (the server cannot see the bot PC's process). No log tail: that is the PC's log.""" from .run_status import exit_text now = now or datetime.now() out = {"running": False, "starting": False, "started_at": None, "ended_at": None, "exit_code": None, "stopped_by_operator": False, "source": None, "started_by": None, "phase": "", "progress": None, "current": None, "counts": {"Pass": 0, "Fail": 0, "Review": 0}, "tail": [], "claims": [], "results_run_at": None, "elapsed_s": None, "message": "No run yet"} try: ids = source.run_ids() except Exception: return out for run_id in ids: found = source.events([run_id]) if run_id not in found or not found[run_id]: continue events = found[run_id] r = summarize(run_id, events, None, now) ended = any(e["kind"] in ("run_end", "stopped") for e in events) fresh = (now - events[-1]["_t"]).total_seconds() < LIVE_STALE_S running = not ended and fresh stopped = r["status"] == "Stopped" out.update( running=running, source=r["source"], started_by=r["started_by"], started_at=r["started"].isoformat() if r["started"] else None, ended_at=r["ended"].isoformat() if r["ended"] else None, exit_code=r["exit_code"], stopped_by_operator=stopped, progress=([r["claims_done"], r["claims_total"]] if r["claims_total"] else None), counts={"Pass": r["pass"], "Fail": r["fail"], "Review": r["review"]}, elapsed_s=r["duration_s"], message=(f"Running on the bot PC · {r['claims_done']} of " f"{r['claims_total']} claims" if running else exit_text(r["exit_code"], stopped))) return out return out