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

+124 -29
View File
@@ -64,12 +64,17 @@ def read_events(logs_dir, run_id) -> list:
if not valid_run_id(run_id):
return []
path = Path(logs_dir) / f"events_{run_id}.jsonl"
out = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
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:
e = json.loads(line)
except ValueError:
@@ -224,34 +229,79 @@ def _time_of_id(run_id):
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):
"""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):
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 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,
day_to=None, status=None) -> list:
"""Every Run with an events file, newest first, filtered. `day_from`
/ `day_to` are ISO dates (inclusive), `status` one of STATUSES."""
"""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
out = []
try:
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True)
except OSError:
files = []
for path in files:
run_id = path.stem[len("events_"):]
if not valid_run_id(run_id):
continue
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
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:
continue
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):
"""The newest Run's summary, or None — reads one file, not all."""
try:
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True)
except OSError:
return None
for path in files:
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)
"""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."""
shots = [e for e in read_events(logs_dir, run_id)
if e["kind"] == "screenshot"]
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:
@@ -290,3 +338,50 @@ def screenshot_path(logs_dir, run_id, n, screenshots_root):
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