Add rpa-run-tab skill: Tebra-style Run tab in 5 minutes
The Run tab from Tebra Auto Post, scrubbed and themable (Tebra Sky, TVL Teal, or a custom hex checked for contrast). The skill wires the real bot through tagged log-file lines and checks itself with a fake bot (complete, partial, stuck, crash) plus light and dark screenshots. Grill decisions R1-R14 are in PLAN.md, terms in GLOSSARY.md, and ADRs 0001-0003. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8bb1cf268e
commit
258bfbb500
18 files changed
+1712
-2
No files matched your search
@@ -0,0 +1,152 @@
|
||||
"""Runs the bot in its own process and turns its log file into Run tab events.
|
||||
|
||||
No Qt in here (ADR-0002): the bot runs as a separate process with no Qt, writes every line
|
||||
to a log file, and this module reads that file every 0.2 s. It does not read a stdout pipe,
|
||||
because a windowed PyInstaller child's pipe is block-buffered and lines arrive late.
|
||||
|
||||
Bot lines it understands:
|
||||
[CPSTATE] active N / [CPSTATE] done N checkpoint progress (ADR-0001)
|
||||
[UI] text, [UI:ok] / [UI:warn] / [UI:error] text lines the operator sees
|
||||
Error / exception lines always shown, even untagged
|
||||
__RUN_SUMMARY__{"total":n,"ok":n,"failed":[[item, reason], ...]} the outcome
|
||||
Everything else stays in the log file only.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
SUMMARY = "__RUN_SUMMARY__"
|
||||
_CP = re.compile(r"^\[CPSTATE\]\s+(active|done)\s+(\d+)")
|
||||
_UI = re.compile(r"^\[UI(?::(ok|warn|error))?\]\s?(.*)$")
|
||||
# Untagged lines that still reach the screen: the last line of a traceback
|
||||
# ("ValueError: ...") and lines starting with ERROR.
|
||||
# ponytail: a regex guess, so add a [UI:error] line to the bot when an error shows badly.
|
||||
_ERR = re.compile(r"^([A-Za-z_][\w.]*(Error|Exception)\b|ERROR\b)")
|
||||
_UI_TYPE = {None: "info", "ok": "success", "warn": "warn", "error": "error"}
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def parse(line):
|
||||
"""One bot line -> (event, data), or None when it stays off screen."""
|
||||
m = _CP.match(line)
|
||||
if m:
|
||||
return "cpstate", {"kind": m[1], "cp": int(m[2])}
|
||||
m = _UI.match(line)
|
||||
if m:
|
||||
return "log", {"msg": m[2], "type": _UI_TYPE[m[1]]}
|
||||
if _ERR.match(line):
|
||||
return "log", {"msg": line, "type": "error"}
|
||||
return None
|
||||
|
||||
|
||||
def outcome(summary, stuck_name=None):
|
||||
"""(state, message) for the page: done (green), partial (amber) or error (red)."""
|
||||
if stuck_name:
|
||||
return "error", f"Stuck at {stuck_name}, run again"
|
||||
if summary is None:
|
||||
return "error", "Failed: the bot stopped without a summary (see the run log)"
|
||||
total = int(summary.get("total") or 0)
|
||||
failed = summary.get("failed") or []
|
||||
if failed and len(failed) >= total:
|
||||
return "error", f"Failed: all {len(failed)} item(s) failed"
|
||||
if failed:
|
||||
return "partial", f"Finished: {len(failed)} of {total} failed"
|
||||
return "done", f"Completed: {total} item(s)" if total else "Completed"
|
||||
|
||||
|
||||
def kill_tree(proc, kill_also=()):
|
||||
"""Kill the bot and everything it started, then any extra .exe names (e.g. a driver)."""
|
||||
if proc is not None and proc.poll() is None:
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
||||
capture_output=True, timeout=15, creationflags=_NO_WINDOW)
|
||||
else:
|
||||
proc.kill() # ponytail: direct child only off Windows; use a process group if bots spawn children there
|
||||
for image in kill_also:
|
||||
subprocess.run(["taskkill", "/F", "/IM", image],
|
||||
capture_output=True, timeout=10, creationflags=_NO_WINDOW)
|
||||
|
||||
|
||||
class Run:
|
||||
"""One bot run. emit(event, data) is called from the run thread."""
|
||||
|
||||
def __init__(self, cmd, log_path, checkpoints, emit, kill_also=(), cwd=None):
|
||||
self.cmd, self.log_path, self.emit = cmd, str(log_path), emit
|
||||
self.checkpoints = checkpoints # [(name, max_seconds)], 0 = no limit
|
||||
self.kill_also, self.cwd = kill_also, cwd
|
||||
self.proc = None
|
||||
self.summary = None
|
||||
self.active, self.since, self.stuck = 0, 0.0, None
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
|
||||
def start(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def kill(self):
|
||||
kill_tree(self.proc, self.kill_also)
|
||||
|
||||
def _line(self, line):
|
||||
if line.startswith(SUMMARY):
|
||||
try:
|
||||
self.summary = json.loads(line[len(SUMMARY):])
|
||||
except ValueError:
|
||||
self.emit("log", {"msg": "Could not read the bot's summary line", "type": "warn"})
|
||||
return
|
||||
ev = parse(line)
|
||||
if not ev:
|
||||
return
|
||||
if ev[0] == "cpstate":
|
||||
kind, n = ev[1]["kind"], ev[1]["cp"]
|
||||
if kind == "active":
|
||||
self.active, self.since = n, time.monotonic()
|
||||
elif n == self.active:
|
||||
self.active = 0
|
||||
self.emit(*ev)
|
||||
|
||||
def _check_stuck(self):
|
||||
if not self.active or self.active > len(self.checkpoints):
|
||||
return False
|
||||
name, limit = self.checkpoints[self.active - 1]
|
||||
if limit and time.monotonic() - self.since > limit:
|
||||
self.stuck = name
|
||||
self.emit("cpstate", {"kind": "stuck", "cp": self.active})
|
||||
self.emit("log", {"msg": f"{name} took longer than {limit}s, stopping the run", "type": "error"})
|
||||
self.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _run(self):
|
||||
self.emit("status", {"state": "running", "msg": "Running automation..."})
|
||||
open(self.log_path, "w", encoding="utf-8").close()
|
||||
try:
|
||||
self.proc = subprocess.Popen(self.cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
cwd=self.cwd, creationflags=_NO_WINDOW)
|
||||
except OSError as e:
|
||||
self.emit("status", {"state": "error", "msg": f"Could not start the bot: {e}"})
|
||||
return
|
||||
buf = ""
|
||||
with open(self.log_path, encoding="utf-8", errors="replace") as f:
|
||||
while True:
|
||||
exited = self.proc.poll() is not None # check before reading, so the last lines are never lost
|
||||
chunk = f.read()
|
||||
if chunk:
|
||||
buf += chunk
|
||||
*lines, buf = buf.split("\n")
|
||||
for ln in lines:
|
||||
self._line(ln.rstrip("\r"))
|
||||
elif exited or self.stuck:
|
||||
break
|
||||
elif self._check_stuck():
|
||||
self.proc.wait(timeout=30)
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
if buf.strip():
|
||||
self._line(buf.rstrip("\r"))
|
||||
for item, reason in (self.summary or {}).get("failed") or []:
|
||||
self.emit("log", {"msg": f"✕ {item}: {reason}", "type": "error"})
|
||||
state, msg = outcome(None if self.stuck else self.summary, self.stuck)
|
||||
self.emit("status", {"state": state, "msg": msg})
|
||||
Reference in new issue
Block a user