Run events in the database: Runs page and Agent run card on the Dokploy Portal

Snapshot of D:\Ecw Process 65c92c5 (2026-10-30).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-30 11:28:26 +05:30
1 parent d500f750da
commit 840272032a
9 files changed
+655 -46

No files matched your search

+35 -6
View File
@@ -348,7 +348,7 @@ that box and on the per-claim save box means Cancel.
| `flows/recovery.py` | A stuck screen inside a pass (§2): `recover()` (logout check, Escape + Patient Information close + clear, reopen search) and `run_claim()` (one retry per claim, 2 in a row → `PageStuck`, exit 9) | | `flows/recovery.py` | A stuck screen inside a pass (§2): `recover()` (logout check, Escape + Patient Information close + clear, reopen search) and `run_claim()` (one retry per claim, 2 in a row → `PageStuck`, exit 9) |
| `core/notify.py` | The agent's mail (§2): `incident()` / `summary()`; claim numbers masked, login / error PNGs only; never fatal | | `core/notify.py` | The agent's mail (§2): `incident()` / `summary()`; claim numbers masked, login / error PNGs only; never fatal |
| `core/watchdog.py` | Claim watchdog (2026-10-22): `watching(limit)` around the claim loop, `beat(where)` at each claim step and every message box, `check()` raises `Stalled` → exit 8; on a stall it closes this process's Playwright driver (`node.exe`) so the frozen call returns, and exits 8 itself if the main thread never comes back | | `core/watchdog.py` | Claim watchdog (2026-10-22): `watching(limit)` around the claim loop, `beat(where)` at each claim step and every message box, `check()` raises `Stalled` → exit 8; on a stall it closes this process's Playwright driver (`node.exe`) so the frozen call returns, and exits 8 itself if the main thread never comes back |
| `core/run_events.py` | A Run's timed events → `Logs\events_<run>.jsonl` (`start`, `emit`, `warning`, `claim_error`, `run_error` with traceback, `login_attempt_start/end`, `login_step`); never raises, never writes an OTP code / password / username | | `core/run_events.py` | A Run's timed events → `Logs\events_<run>.jsonl` (`start`, `emit`, `warning`, `claim_error`, `run_error` with traceback, `login_attempt_start/end`, `login_step`); never raises, never writes an OTP code / password / username. Since `c9a43ac` (2026-10-30) each line is also copied to `dbo.CV_RunEvent` (§5) — only for a Run in the real `Logs` folder with `RESULTS_DB=1`; the first database error turns the copy off for that Run |
| `core/browser.py` | `launch()`: Playwright Chromium with the eCW proxy, or (`BROWSER_CDP_URL`) attach to the operator's Chrome — `clear_ecw_data()` at attach and close, `cdp_ready()` checked before login | | `core/browser.py` | `launch()`: Playwright Chromium with the eCW proxy, or (`BROWSER_CDP_URL`) attach to the operator's Chrome — `clear_ecw_data()` at attach and close, `cdp_ready()` checked before login |
| `core/msgbox.py` | Timed Windows message boxes; `default_cancel=True` makes Enter mean Cancel | | `core/msgbox.py` | Timed Windows message boxes; `default_cancel=True` makes Enter mean Cancel |
| `core/text.py` | `first_line()` shared by core / conditions / flows | | `core/text.py` | `first_line()` shared by core / conditions / flows |
@@ -641,6 +641,24 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
queue file — the agent would have to write the queue size to the DB). queue file — the agent would have to write the queue size to the DB).
The mockup canvas: claude.ai artifact "Charge Validation Portal Mockups" The mockup canvas: claude.ai artifact "Charge Validation Portal Mockups"
(private to the operator). (private to the operator).
- **Runs on Dokploy (2026-10-30, `c9a43ac`).** The Dokploy Portal cannot
read the bot PC's `Logs` folder, so its Runs page showed "No runs
match" for every date. Now every Run event is also a row in
`dbo.CV_RunEvent` (run_id, at, kind, body = the same JSON line;
created in SSMS by the operator that day, script in
`tools/sql/create_tables.sql`; `cv_portal` reads it through
`db_datareader`). `web/runs.py` reads Runs through an events source:
`FileEvents` (the PC's local Portal, as before) or `DbEvents` (a
`PORTAL_SERVER` Portal; one query per page). On Dokploy the Runs list,
filter, run detail and last-run banner come from the table, and the
Dashboard's Agent run card from `runs.live_status()` (the newest Run;
"running" = no end and an event in the last 10 minutes). Failure
screenshots stay on the PC (PHI): a server shows their names only.
`tools/import_run_events.py` loaded the 34 older Runs (2,043 events,
22–29/10) once; a second run adds nothing. Checked live: 28–29/10 lists
15 Runs; the 21:10 Run's detail shows the 7 m 7 s 2FA mail wait. ADR
0001 (run events in files) still holds: the file is the record, the row
a copy.
--- ---
@@ -753,7 +771,9 @@ the Portal (restarted 2026-10-26, [portal] terminal tab).
History: `git log --oneline`. History: `git log --oneline`.
Last code commits: `78409f0` (2026-10-30, Portal sage redesign — §5; Last code commits: `c9a43ac` (2026-10-30, run events in the database —
Runs page and Agent run card on Dokploy, §5; deployed from
`portal-20261030-runs.zip`), `78409f0` (2026-10-30, Portal sage redesign — §5;
deployed to Dokploy from the working tree before this commit, same deployed to Dokploy from the working tree before this commit, same
files), `24c9ce4` (2026-10-30, the self-healing agent: supervisor, files), `24c9ce4` (2026-10-30, the self-healing agent: supervisor,
recovery, security image Continue, Resend pickup, operator mail — §2, recovery, security image Continue, Resend pickup, operator mail — §2,
@@ -898,10 +918,19 @@ test claim ids — new posts must not (§7).
`.env` (the operator's address) and one test mail; a first `python `.env` (the operator's address) and one test mail; a first `python
agent.py` to see the security image Continue click on the real page agent.py` to see the security image Continue click on the real page
(its HTML is saved as `security_image_page` — tighten the selector from (its HTML is saved as `security_image_page` — tighten the selector from
it) and a late-mail Resend pickup. Not built: the agent's status in the it) and a late-mail Resend pickup. The Dokploy Dashboard's Agent run
Portal (it runs on the bot PC; the Dokploy Portal sees only the card shows the newest bot Run from `CV_RunEvent` (`c9a43ac`), but not the
database — a status / queue-size row would carry it), Queue / Pending agent's own passes or waits (the supervisor writes no events yet), and
tiles. there are no Queue / Pending tiles (no queue size in the database).
- **Dokploy wording (2026-10-30, asked, not done):** the Agent run card
says "see the log" and "CAPTCHA and 2FA boxes still come up on this PC"
— true for the PC's local Portal, misleading on Dokploy; reword for a
server Portal.
- **A public IP in the Dokploy logs (2026-10-30):** `/api/bot/status`
polls from `43.254.111.194` beside the internal `10.200.1.15`. The
Portal is meant to be Ecare network / VPN only — IT to confirm the
address is Ecare's own; if not, the domain is reachable from the
internet (PHI).
- **29/10 live queue (690 claims).** 208 got a result in two runs (17:18 - **29/10 live queue (690 claims).** 208 got a result in two runs (17:18
126, 19:29 82); the rest were not reached: Ctrl-C would not stop a 126, 19:29 82); the rest were not reached: Ctrl-C would not stop a
run blocked in a Playwright call (the tab was closed instead), a stuck run blocked in a Playwright call (the tab was closed instead), a stuck
+68 -5
View File
@@ -8,6 +8,11 @@ writes `Logs\\events_<run>.jsonl` beside `run_<run>.log`: one JSON object a
line, each with its time to the millisecond, flushed at once so a killed Run line, each with its time to the millisecond, flushed at once so a killed Run
keeps what it wrote. The Portal's Runs page reads these files. keeps what it wrote. The Portal's Runs page reads these files.
Every event is also copied to `dbo.CV_RunEvent` (2026-10-30, with
`RESULTS_DB=1`): the Portal on Dokploy cannot read this PC's Logs folder.
The file stays the record; the row is the same JSON line. The first
database error switches the copy off for the rest of the Run.
An event never stops a Run: anything that goes wrong writing one drops that An event never stops a Run: anything that goes wrong writing one drops that
event and nothing else. Nothing secret goes in — no OTP code, no password, event and nothing else. Nothing secret goes in — no OTP code, no password,
no eCW username: the callers pass fixed fields, never a whole log line. no eCW username: the callers pass fixed fields, never a whole log line.
@@ -22,6 +27,9 @@ from pathlib import Path
_handle = None _handle = None
_attempt = 0 _attempt = 0
_run_id = ""
_db_on = True
_conn = None
def close() -> None: def close() -> None:
@@ -31,13 +39,62 @@ def close() -> None:
def _reset() -> None: def _reset() -> None:
"""Close the file and forget the attempt count (tests; a new Run).""" """Close the file and forget the attempt count (tests; a new Run)."""
global _handle, _attempt global _handle, _attempt, _run_id, _db_on, _conn
try: try:
if _handle: if _handle:
_handle.close() _handle.close()
except Exception: except Exception:
pass pass
_handle, _attempt = None, 0 try:
if _conn is not None:
_conn.close()
except Exception:
pass
_handle, _attempt, _run_id, _db_on, _conn = None, 0, "", True, None
def _sql_insert(run_id, at, kind, body) -> None:
"""The real copy: one INSERT on a connection kept for the Run."""
global _conn
from .. import settings as cfg
# A test that points LOGS at a temp folder never writes the real table.
if not cfg.RESULTS_DB or \
Path(cfg.LOGS).resolve() != (Path(cfg.ROOT) / "Logs").resolve():
return
if _conn is None:
import pyodbc
_conn = pyodbc.connect(cfg.RESULTS_DB_CONN, timeout=10,
autocommit=True)
_conn.cursor().execute(
"INSERT INTO dbo.CV_RunEvent (run_id, at, kind, body) "
"VALUES (?, ?, ?, ?)", [run_id, at, kind, body])
_db_writer = _sql_insert
def _real_run(log_path) -> bool:
"""A Run in the real Logs folder: a test's log in a temp folder never
reaches the database, whatever this PC's `.env` says."""
try:
from .. import settings as cfg
return Path(log_path).resolve().parent == Path(cfg.LOGS).resolve()
except Exception:
return False
def _copy(run_id: str, kind: str, line: str) -> None:
"""The line to the database too. Never raises."""
global _db_on
if not _db_on or not run_id:
return
try:
_db_writer(run_id, datetime.now(), kind, line)
except Exception as exc:
_db_on = False
why = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
print(f"[EVENTS] Not copied to the database for the rest of this "
f"run: {why}")
def run_id_of(log_path) -> str: def run_id_of(log_path) -> str:
@@ -77,8 +134,9 @@ def _line(kind: str, fields: dict) -> str:
def start(log_path, mode: str, claim_limit=None, test_sheet: str = "") -> None: def start(log_path, mode: str, claim_limit=None, test_sheet: str = "") -> None:
"""Open this Run's events file and write `run_start`.""" """Open this Run's events file and write `run_start`."""
global _handle global _handle, _run_id
_reset() _reset()
_run_id = run_id_of(log_path) if _real_run(log_path) else ""
try: try:
_handle = open(path_for(log_path), "a", encoding="utf-8", _handle = open(path_for(log_path), "a", encoding="utf-8",
buffering=1) buffering=1)
@@ -98,20 +156,25 @@ def emit(kind: str, **fields) -> None:
"""Write one event. Never raises.""" """Write one event. Never raises."""
if _handle is None: if _handle is None:
return return
line = _line(kind, fields)
try: try:
_handle.write(_line(kind, fields) + "\n") _handle.write(line + "\n")
_handle.flush() _handle.flush()
except Exception: except Exception:
pass pass
_copy(_run_id, kind, line)
def append_to(log_path, kind: str, **fields) -> None: def append_to(log_path, kind: str, **fields) -> None:
"""Add one event to another Run's file — the Portal, after a Stop.""" """Add one event to another Run's file — the Portal, after a Stop."""
line = _line(kind, fields)
try: try:
with open(path_for(log_path), "a", encoding="utf-8") as fh: with open(path_for(log_path), "a", encoding="utf-8") as fh:
fh.write(_line(kind, fields) + "\n") fh.write(line + "\n")
except Exception: except Exception:
pass pass
if _real_run(log_path):
_copy(run_id_of(log_path), kind, line)
def warning(code: str, message: str, **fields) -> None: def warning(code: str, message: str, **fields) -> None:
+129
View File
@@ -0,0 +1,129 @@
r"""
core/run_events.py — every event also goes to `dbo.CV_RunEvent` (operator,
2026-10-30): the Portal on Dokploy cannot read the bot PC's Logs folder, so
its Runs page showed "No runs match" for every date.
The file stays the record of a Run; the database row is a copy. A database
that is off, missing the table or down never stops a Run — after the first
failure the copy is switched off for the rest of the Run, said once in the
log.
python tests\test_run_events_db.py
No database: the writer is a stand-in.
"""
import io
import sys
import json
import pathlib
import tempfile
import contextlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
from ecw_bot import settings as cfg # noqa: E402
from ecw_bot.core import run_events # noqa: E402
CASES = []
def case(fn):
CASES.append(fn)
return fn
def _start(tmp, writer):
run_events._db_writer = writer
log = pathlib.Path(tmp) / "run_20261030_101500.log"
run_events.start(log, "VERIFY")
return log
@case
def every_event_is_copied_with_its_run_id_and_kind(tmp):
rows = []
_start(tmp, lambda run_id, at, kind, body: rows.append(
(run_id, kind, json.loads(body))))
run_events.emit("claim_start", n=1, total=2, claim="9103000")
run_events.close()
assert [k for _, k, _ in rows] == ["run_start", "claim_start"], rows
assert {r for r, _, _ in rows} == {"20261030_101500"}
assert rows[1][2]["claim"] == "9103000" and rows[1][2]["t"], rows
@case
def the_file_and_the_row_carry_the_same_line(tmp):
bodies = []
log = _start(tmp, lambda run_id, at, kind, body: bodies.append(body))
run_events.emit("warning", code="x", message="y")
run_events.close()
lines = run_events.path_for(log).read_text(encoding="utf-8").splitlines()
assert lines == bodies, (lines, bodies)
@case
def a_database_error_switches_the_copy_off_and_the_run_goes_on(tmp):
calls = []
def boom(*a):
calls.append(a)
raise OSError("no such table")
out = io.StringIO()
with contextlib.redirect_stdout(out):
log = _start(tmp, boom)
run_events.emit("claim_start", n=1)
run_events.emit("claim_start", n=2)
run_events.close()
assert len(calls) == 1, "tried once, then off"
assert out.getvalue().count("[EVENTS]") == 1, out.getvalue()
assert len(run_events.path_for(log).read_text().splitlines()) == 3
@case
def an_event_added_after_a_stop_is_copied_too(tmp):
rows = []
run_events._db_writer = lambda run_id, at, kind, body: rows.append(kind)
log = pathlib.Path(tmp) / "run_20261030_101500.log"
run_events.append_to(log, "stopped", by="sam")
assert rows == ["stopped"], rows
@case
def a_run_outside_the_logs_folder_is_never_copied(tmp):
rows = []
run_events._db_writer = lambda *a: rows.append(a)
other = pathlib.Path(tmp) / "elsewhere"
other.mkdir()
run_events.start(other / "run_20261030_101500.log", "VERIFY")
run_events.emit("claim_start", n=1)
run_events.close()
run_events.append_to(other / "run_20261030_101500.log", "stopped")
assert rows == [], "a test's run never reaches the database"
def main() -> int:
failures = 0
real = run_events._db_writer
for fn in CASES:
name = fn.__name__.replace("_", " ")
with tempfile.TemporaryDirectory() as tmp:
cfg.LOGS = pathlib.Path(tmp) # a real-looking Logs folder
try:
fn(tmp)
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
finally:
run_events._reset()
run_events._db_writer = real
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+177
View File
@@ -0,0 +1,177 @@
r"""
web/runs.py from the database (operator, 2026-10-30): the Portal on Dokploy
cannot read the bot PC's Logs folder, so its Runs page said "No runs match"
for every date. With `DbEvents` the same summaries come from
`dbo.CV_RunEvent` — the same JSON lines the files hold.
python tests\test_runs_from_db.py
No database: the repo is a stand-in.
"""
import sys
import json
import pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "tests"))
sys.path.insert(0, str(ROOT))
from web import runs # noqa: E402
CASES = []
def case(fn):
CASES.append(fn)
return fn
def _line(day, t, kind, **f):
return json.dumps({**f, "t": f"2026-10-{day}T{t}.000", "kind": kind})
def _run(day, hhmmss, end_code=0):
t = f"{hhmmss[:2]}:{hhmmss[2:4]}:{hhmmss[4:]}"
run_id = f"202610{day}_{hhmmss}"
lines = [_line(day, t, "run_start", source="terminal", started_by="ops",
mode="VERIFY", claim_limit=0, test_sheet="", pid=1,
log="x"),
_line(day, t, "claim_start", n=1, total=1, claim="9103000"),
_line(day, t, "run_end", exit_code=end_code)]
return run_id, lines
class FakeEventsRepo:
def __init__(self, *runs_):
self.rows = [(rid, line) for rid, lines in runs_ for line in lines]
self.calls = []
def run_ids(self):
self.calls.append(("run_ids",))
return sorted({r for r, _ in self.rows}, reverse=True)
def run_event_lines(self, run_ids):
self.calls.append(("run_event_lines", tuple(run_ids)))
return [(r, b) for r, b in self.rows if r in set(run_ids)]
@case
def runs_come_from_the_database_newest_first_and_filtered_by_day():
repo = FakeEventsRepo(_run("28", "161611"), _run("29", "163000"),
_run("29", "170620", end_code=9))
src = runs.DbEvents(repo)
got = runs.list_runs(src, day_from="2026-10-29", day_to="2026-10-29")
assert [r["run_id"] for r in got] == ["20261029_170620",
"20261029_163000"], got
assert got[0]["status"] == "Run Error" and got[1]["status"] == "Done"
assert repo.calls[-1] == ("run_event_lines",
("20261029_170620", "20261029_163000")), \
"one query for the day's runs, not one per run"
@case
def one_run_and_the_latest_run_come_from_the_database():
repo = FakeEventsRepo(_run("28", "161611"), _run("29", "163000"))
src = runs.DbEvents(repo)
assert runs.load_run(src, "20261028_161611")["status"] == "Done"
assert runs.load_run(src, "20261030_000000") is None
assert runs.latest_run(src)["run_id"] == "20261029_163000"
@case
def a_screenshot_is_never_served_from_the_database():
repo = FakeEventsRepo(("20261029_163000", [
_line("29", "16:30:00", "screenshot", path="C:/x/101500_shot.png")]))
assert runs.screenshot_path(runs.DbEvents(repo), "20261029_163000", 0,
"C:/x") is None
@case
def the_portal_runs_page_reads_its_events_source():
import test_web_app as t
repo = FakeEventsRepo(_run("29", "163000"))
client, _ = t.signed_in(t.FakeRepo(), t.FakeRunner(),
events=runs.DbEvents(repo))
html = client.get("/runs", params={"day_from": "2026-10-29",
"day_to": "2026-10-29"}).text
assert "No runs match" not in html, html[-800:]
assert "/runs/20261029_163000" in html
def _live_lines(day, hhmm, ss_last, end=False):
"""A run whose last event is at hh:mm:ss_last; ended or not."""
run_id = f"202610{day}_{hhmm}00"
t0 = f"{hhmm[:2]}:{hhmm[2:]}:00"
lines = [_line(day, t0, "run_start", source="terminal", started_by="ops",
mode="VERIFY", claim_limit=0, test_sheet="", pid=1,
log="x"),
_line(day, t0, "queue", claims=["9103000", "9104000"]),
_line(day, t0, "claim_start", n=1, total=2, claim="9103000"),
_line(day, f"{hhmm[:2]}:{hhmm[2:]}:{ss_last}", "claim_result",
n=1, claim="9103000", category="Fail")]
if end:
lines.append(_line(day, f"{hhmm[:2]}:{hhmm[2:]}:{ss_last}",
"run_end", exit_code=0))
return run_id, lines
@case
def the_server_dashboard_shows_a_run_still_writing_events_as_running():
from datetime import datetime
repo = FakeEventsRepo(_live_lines("30", "1015", "40"))
st = runs.live_status(runs.DbEvents(repo),
now=datetime(2026, 10, 30, 10, 17, 0))
assert st["running"] is True and st["progress"] == [1, 2], st
assert st["counts"] == {"Pass": 0, "Fail": 1, "Review": 0}, st
assert st["source"] == "terminal" and st["tail"] == [], st
@case
def the_server_dashboard_shows_an_ended_or_silent_run_as_ended():
from datetime import datetime
repo = FakeEventsRepo(_live_lines("30", "1015", "40", end=True))
st = runs.live_status(runs.DbEvents(repo),
now=datetime(2026, 10, 30, 10, 17, 0))
assert st["running"] is False and st["exit_code"] == 0, st
assert "Done" in st["message"] or "exit 0" in st["message"].lower() \
or st["message"], st
silent = FakeEventsRepo(_live_lines("30", "1015", "40"))
st = runs.live_status(runs.DbEvents(silent),
now=datetime(2026, 10, 30, 11, 30, 0))
assert st["running"] is False, "no event for over 10 minutes"
assert runs.live_status(runs.DbEvents(FakeEventsRepo()))["running"] \
is False
@case
def the_server_portals_bot_status_comes_from_the_database():
import test_web_app as t
repo = FakeEventsRepo(_live_lines("30", "1015", "40", end=True))
client, _ = t.signed_in(t.FakeRepo(), t.FakeRunner(),
events=runs.DbEvents(repo))
st = client.get("/api/bot/status").json()
assert st["running"] is False and st["exit_code"] == 0, st
assert st["counts"]["Fail"] == 1 and st["progress"] == [1, 2], st
def main() -> int:
failures = 0
for fn in CASES:
name = fn.__name__.replace("_", " ")
try:
fn()
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
r"""
Load this PC's older Run events into `dbo.CV_RunEvent` (2026-10-30), once,
so the Portal on Dokploy lists the Runs from before the bot copied its
events to the database itself. A Run already in the table is skipped, so a
second run of this tool adds nothing.
python tools\import_run_events.py # import
python tools\import_run_events.py --dry-run # only say what it would do
Run it from PowerShell on the bot PC (Windows login to the database).
"""
import sys
import json
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
from ecw_bot import settings as cfg # noqa: E402
RUN_PREFIX = "events_"
def rows_of(run_id: str, text: str) -> list:
"""(run_id, at, kind, line) per timed JSON line; a bad line skipped."""
out = []
for line in text.splitlines():
try:
e = json.loads(line)
at = datetime.fromisoformat(e["t"])
except (ValueError, KeyError, TypeError):
continue
out.append((run_id, at, str(e.get("kind", ""))[:40], line))
return out
def main(argv) -> int:
dry = "--dry-run" in argv
import pyodbc
conn = pyodbc.connect(cfg.RESULTS_DB_CONN, timeout=15)
cur = conn.cursor()
have = {r[0] for r in cur.execute(
"SELECT DISTINCT run_id FROM dbo.CV_RunEvent").fetchall()}
added_runs = added_rows = 0
for path in sorted(Path(cfg.LOGS).glob(f"{RUN_PREFIX}*.jsonl")):
run_id = path.stem[len(RUN_PREFIX):]
if run_id in have:
continue
rows = rows_of(run_id, path.read_text(encoding="utf-8",
errors="replace"))
if not rows:
continue
added_runs += 1
added_rows += len(rows)
if not dry:
cur.executemany(
"INSERT INTO dbo.CV_RunEvent (run_id, at, kind, body) "
"VALUES (?, ?, ?, ?)", rows)
if not dry:
conn.commit()
conn.close()
verb = "Would add" if dry else "Added"
print(f"{verb} {added_runs} run(s), {added_rows} event(s); "
f"{len(have)} run(s) were already in the table.")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+17
View File
@@ -104,6 +104,23 @@ BEGIN
END END
GO GO
-- Every Run's events (2026-10-30): the same JSON lines as the bot PC's
-- Logs\events_<run>.jsonl, so the Portal on Dokploy has a Runs page and an
-- Agent run card. Written by the bot (db_datawriter), read by cv_portal
-- (db_datareader). tools/import_run_events.py loads the older files once.
IF OBJECT_ID('dbo.CV_RunEvent', 'U') IS NULL
BEGIN
CREATE TABLE dbo.CV_RunEvent (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
run_id VARCHAR(15) NOT NULL, -- 20261030_101500, the run log's stamp
at DATETIME2(3) NOT NULL,
kind NVARCHAR(40) NOT NULL, -- run_start, claim_start, run_end, ...
body NVARCHAR(MAX) NOT NULL -- the event's JSON line
);
CREATE INDEX IX_CV_RunEvent_run ON dbo.CV_RunEvent (run_id, id);
END
GO
-- So the bot can connect with your Windows login (no password), give that -- So the bot can connect with your Windows login (no password), give that
-- login read/write here. Uncomment and run once, as an admin (e.g. sa): -- login read/write here. Uncomment and run once, as an admin (e.g. sa):
-- --
+16 -6
View File
@@ -325,12 +325,15 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
allowed_origins=ALLOWED_ORIGINS, logs_dir=None, allowed_origins=ALLOWED_ORIGINS, logs_dir=None,
screenshots_root=None, users_store=None, screenshots_root=None, users_store=None,
sessions=None, can_start=True, sessions=None, can_start=True,
secure_cookie=False, ecare=None) -> FastAPI: secure_cookie=False, ecare=None, events=None) -> FastAPI:
app = FastAPI(title="eCW Charge Validation — Results", app = FastAPI(title="eCW Charge Validation — Results",
docs_url=None, redoc_url=None, openapi_url=None) docs_url=None, redoc_url=None, openapi_url=None)
if runner is None: if runner is None:
runner = BotRunner() if can_start else NoRunner() runner = BotRunner() if can_start else NoRunner()
logs_dir = logs_dir if logs_dir is not None else cfg.LOGS logs_dir = logs_dir if logs_dir is not None else cfg.LOGS
# Where the Runs page and the last-run banner read Runs from: this PC's
# Logs folder, or (the Portal on Dokploy, 2026-10-30) the database.
events = events if events is not None else run_view.FileEvents(logs_dir)
screenshots_root = (screenshots_root if screenshots_root is not None screenshots_root = (screenshots_root if screenshots_root is not None
else cfg.ROOT / "Screenshots") else cfg.ROOT / "Screenshots")
# Made per server start and put in the page: a page on another site # Made per server start and put in the page: a page on another site
@@ -554,7 +557,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
"""The latest Run, if it ended badly — the Dashboard's red banner """The latest Run, if it ended badly — the Dashboard's red banner
(EEWA-33). A Run Error, or a Run that ended without a record (EEWA-33). A Run Error, or a Run that ended without a record
(killed); never a Stop, a finished Run or one still going.""" (killed); never a Stop, a finished Run or one still going."""
last = run_view.latest_run(logs_dir, _live_run_id()) last = run_view.latest_run(events, _live_run_id())
if last and last["status"] in ("Run Error", "Ended without a record"): if last and last["status"] in ("Run Error", "Ended without a record"):
return last return last
return None return None
@@ -580,7 +583,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
raise BadFilter(f"Not a date: {value}") raise BadFilter(f"Not a date: {value}")
if status and status not in run_view.STATUSES: if status and status not in run_view.STATUSES:
raise BadFilter(f"Not a result: {status}") raise BadFilter(f"Not a result: {status}")
rows = run_view.list_runs(logs_dir, _live_run_id(), rows = run_view.list_runs(events, _live_run_id(),
day_from=day_from or None, day_from=day_from or None,
day_to=day_to or None, day_to=day_to or None,
status=status or None) status=status or None)
@@ -592,7 +595,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
@app.get("/runs/{run_id}", response_class=HTMLResponse) @app.get("/runs/{run_id}", response_class=HTMLResponse)
def run_page(request: Request, run_id: str): def run_page(request: Request, run_id: str):
run = run_view.load_run(logs_dir, run_id, _live_run_id()) run = run_view.load_run(events, run_id, _live_run_id())
if run is None: if run is None:
return HTMLResponse("<h1>No such run</h1>", status_code=404) return HTMLResponse("<h1>No such run</h1>", status_code=404)
return _page(request, "run.html", return _page(request, "run.html",
@@ -602,7 +605,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
@app.get("/runs/{run_id}/shot/{n}") @app.get("/runs/{run_id}/shot/{n}")
def run_shot(run_id: str, n: int): def run_shot(run_id: str, n: int):
path = run_view.screenshot_path(logs_dir, run_id, n, screenshots_root) path = run_view.screenshot_path(events, run_id, n, screenshots_root)
if path is None: if path is None:
return HTMLResponse("<h1>No such screenshot</h1>", return HTMLResponse("<h1>No such screenshot</h1>",
status_code=404) status_code=404)
@@ -652,6 +655,10 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
@app.get("/api/bot/status") @app.get("/api/bot/status")
def bot_status(): def bot_status():
# A server Portal cannot see the bot PC's process or log: the
# newest Run in the database instead (2026-10-30).
if isinstance(events, run_view.DbEvents):
return jsonable_encoder(run_view.live_status(events))
return run_status.summarize(runner.status()) return run_status.summarize(runner.status())
# ── Sign-in (Stage 2, EEWA-32) ─────────────────────────────────────── # ── Sign-in (Stage 2, EEWA-32) ───────────────────────────────────────
@@ -873,8 +880,11 @@ def _safe_next(target) -> str:
return target return target
app = create_app(SqlResultsRepo(cfg.RESULTS_DB_CONN), _repo = SqlResultsRepo(cfg.RESULTS_DB_CONN)
app = create_app(_repo,
users_store=SqlUserStore(cfg.RESULTS_DB_CONN), users_store=SqlUserStore(cfg.RESULTS_DB_CONN),
# A server Portal has no Logs folder: Runs from the database.
events=run_view.DbEvents(_repo) if cfg.PORTAL_SERVER else None,
ecare=(EcareSignIn(cfg.AUTH_TOKEN_API_URL) ecare=(EcareSignIn(cfg.AUTH_TOKEN_API_URL)
if cfg.AUTH_TOKEN_API_URL else None), if cfg.AUTH_TOKEN_API_URL else None),
**portal_settings(cfg.PORTAL_URLS, cfg.PORTAL_SERVER)) **portal_settings(cfg.PORTAL_URLS, cfg.PORTAL_SERVER))
+18
View File
@@ -136,3 +136,21 @@ class SqlResultsRepo:
f"SELECT TOP 200 {_SELECT_COLUMNS} FROM {results_db.TABLE} " f"SELECT TOP 200 {_SELECT_COLUMNS} FROM {results_db.TABLE} "
f"WHERE claim_no LIKE ? ORDER BY run_at DESC, s_no, id", f"WHERE claim_no LIKE ? ORDER BY run_at DESC, s_no, id",
[f"%{claim_no}%"]) [f"%{claim_no}%"])
# ── Run events (2026-10-30): the Runs page on a server Portal ─────────
def run_ids(self) -> list:
"""Every Run with events, newest first (at most 500)."""
return [r["run_id"] for r in self._query(
"SELECT TOP 500 run_id FROM dbo.CV_RunEvent "
"GROUP BY run_id ORDER BY run_id DESC")]
def run_event_lines(self, run_ids) -> list:
"""(run_id, JSON line) for those Runs, in the order written."""
run_ids = list(run_ids)[:500]
if not run_ids:
return []
marks = ", ".join("?" * len(run_ids))
return [(r["run_id"], r["body"]) for r in self._query(
f"SELECT run_id, body FROM dbo.CV_RunEvent "
f"WHERE run_id IN ({marks}) ORDER BY run_id, id", run_ids)]
+124 -29
View File
@@ -64,12 +64,17 @@ def read_events(logs_dir, run_id) -> list:
if not valid_run_id(run_id): if not valid_run_id(run_id):
return [] return []
path = Path(logs_dir) / f"events_{run_id}.jsonl" path = Path(logs_dir) / f"events_{run_id}.jsonl"
out = []
try: try:
text = path.read_text(encoding="utf-8", errors="replace") text = path.read_text(encoding="utf-8", errors="replace")
except OSError: except OSError:
return [] return []
for line in text.splitlines(): 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: try:
e = json.loads(line) e = json.loads(line)
except ValueError: except ValueError:
@@ -224,34 +229,79 @@ def _time_of_id(run_id):
return None return None
class FileEvents:
"""Runs from this PC's `Logs/events_<run>.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): def load_run(logs_dir, run_id, live_run_id=None, now=None):
"""One Run's detail, or None (bad id, no events file).""" """One Run's detail, or None (bad id, no events)."""
if not valid_run_id(run_id): if not valid_run_id(run_id):
return None return None
if not (Path(logs_dir) / f"events_{run_id}.jsonl").is_file(): found = _source(logs_dir).events([run_id])
if run_id not in found:
return None return None
return summarize(run_id, read_events(logs_dir, run_id), live_run_id, now) 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, def list_runs(logs_dir, live_run_id=None, now=None, day_from=None,
day_to=None, status=None) -> list: day_to=None, status=None) -> list:
"""Every Run with an events file, newest first, filtered. `day_from` """Every Run with events, newest first, filtered. `day_from` /
/ `day_to` are ISO dates (inclusive), `status` one of STATUSES.""" `day_to` are ISO dates (inclusive), `status` one of STATUSES."""
d_from = date.fromisoformat(day_from) if day_from else None d_from = date.fromisoformat(day_from) if day_from else None
d_to = date.fromisoformat(day_to) if day_to else None d_to = date.fromisoformat(day_to) if day_to else None
out = [] src = _source(logs_dir)
try: ids = []
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True) for run_id in src.run_ids():
except OSError:
files = []
for path in files:
run_id = path.stem[len("events_"):]
if not valid_run_id(run_id):
continue
day = _time_of_id(run_id).date() day = _time_of_id(run_id).date()
if (d_from and day < d_from) or (d_to and day > d_to): if (d_from and day < d_from) or (d_to and day > d_to):
continue continue
r = summarize(run_id, read_events(logs_dir, run_id), live_run_id, now) 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: if status and r["status"] != status:
continue continue
out.append(r) out.append(r)
@@ -259,24 +309,22 @@ def list_runs(logs_dir, live_run_id=None, now=None, day_from=None,
def latest_run(logs_dir, live_run_id=None, now=None): def latest_run(logs_dir, live_run_id=None, now=None):
"""The newest Run's summary, or None — reads one file, not all.""" """The newest Run's summary, or None — reads one Run, not all."""
try: src = _source(logs_dir)
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True) for run_id in src.run_ids():
except OSError: found = src.events([run_id])
return None if run_id in found:
for path in files: return summarize(run_id, found[run_id], live_run_id, now)
run_id = path.stem[len("events_"):]
if valid_run_id(run_id):
return summarize(run_id, read_events(logs_dir, run_id),
live_run_id, now)
return None return None
def screenshot_path(logs_dir, run_id, n, screenshots_root): def screenshot_path(logs_dir, run_id, n, screenshots_root):
"""The n-th screenshot event's .png, if it is inside `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.""" and exists; else None. Only paths a Run itself recorded are served."""
shots = [e for e in read_events(logs_dir, run_id) if not valid_run_id(run_id):
if e["kind"] == "screenshot"] 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): if not 0 <= n < len(shots):
return None return None
try: try:
@@ -290,3 +338,50 @@ def screenshot_path(logs_dir, run_id, n, screenshots_root):
or not path.is_file()): or not path.is_file()):
return None return None
return path 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