Add rpa-run-tab skill

A Tebra-style Run tab for desktop PyQt6 bots, built in about 5 minutes:
Trigger button, Excel input card, progress with timer, live log, checkpoint
boxes with time limits, and green/amber/red endings. It is checked with a
fake bot (run_tab.py --selfcheck). The design decisions are in rpa-run-tab/docs/adr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 16:30:59 +05:30
commit a57b32e8cb
12 files changed
+1435

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
---
name: rpa-run-tab
description: Adds a Tebra-style Run tab to a desktop RPA bot in about 5 minutes. The Run tab is a PyQt6 window (or a tab in an existing PyQt web page) with a Trigger button, an Excel input card (drop zone, sheet tabs, preview with masked passwords, sample download), a progress bar with a timer, a step list, a friendly live log, checkpoint boxes with time limits, and green/amber/red endings. It also wires the real bot to the page. Use when the user asks for a "Run tab", "run screen", "trigger screen", "start/run page for my bot", or "the Tebra run tab" for an RPA or automation project. For a whole dashboard (KPIs, report table), use rpa-dashboard-no-ai.
---
# RPA Run tab
Build the Run tab, wire the real bot to it and check it with the fake bot. The whole job should take about 5 minutes.
The look is fixed. It is Tebra Auto Post's Run tab, and only the colours change.
Everything needed is in `templates/`. Never read the original Tebra projects.
| File | What it is |
|---|---|
| `templates/run_tab.html` | The page. Its config comes from `run_tab.py`, and its theme is between `THEME:START` and `THEME:END`. Each part is fenced with `RUN-TAB:CSS`, `TOPBAR`, `MARKUP` and `JS` markers. |
| `templates/run_tab.py` | The PyQt6 window and bridge. It is also the bot's process (`--run-bot`, which loads no Qt). It holds the **CONFIG** block you fill in and `--selfcheck`. |
| `templates/runner.py` | Qt-free. It starts the bot, reads its log file every 0.2 s, turns lines into events, kills stuck runs and decides the ending. |
| `templates/rt.py` | The protocol helpers the bot calls: `checkpoint`, `checkpoint_done`, `ui`, `result`, `summary`. |
| `templates/fake_bot.py` | Prints the protocol for the endings complete, partial, stuck and crash. Used only by the self-check. |
| `templates/palette.py` | Theme: `sky`, `teal` or a brand hex. Use `--write` to put it into the page. It checks contrast. |
The rules come from the ADRs in `docs/adr/`. Keep to them:
- One checkpoint list drives the steps, the boxes and the progress %.
- The bot talks only through tagged lines in a log file.
- A stuck checkpoint kills the run, and the run fails.
- There is no auto-restart and no Stop or Pause button.
- There is no database, no resume and no roles.
## 1. Scan (about 1 minute, ask nothing yet)
**Never run the bot.** Real bots act on live systems.
1. **Bot entry point.**
- Look for a `main(...)` function, an `if __name__ == "__main__"` script, or a `run_*` function.
- Note its loop over input rows or items. Those become the summary's `total` and `failed`.
- Note its big stages, such as sign in, open a page, process rows, export. Those become the checkpoints.
2. **Input.**
- Does the bot read an Excel file (`openpyxl`, `pandas.read_excel`, `xlrd`)?
- If yes, note the sheet names and column names it reads, for `SAMPLE_SHEETS`.
- No Excel file means `HAS_INPUT = False`.
3. **Host.** Search for `QWebEngineView`, `setHtml` or a `.html` page loaded by PyQt, or an `rpa-dashboard-no-ai` `dashboard.html`. If you find one, the Run tab becomes a tab in it (step 4b). Otherwise it gets its own window (step 4a).
4. **Processes.** Note any extra programs the bot starts and that must die with it, such as `chromedriver.exe` or a desktop app. These go in `KILL_ALSO`.
## 2. Ask once (one message, at most 3 questions)
Send the scan result and these questions in one message, then wait:
1. **Colour:**
- 1) Tebra Sky (blue)
- 2) TVL Teal (green)
- 3) Custom: send one brand hex
2. **Checkpoints:** propose 3–8, each with a name of 2–3 plain words and a time limit, for example `Sign in, max 5 min`. Use `no limit` for long loops over rows. The user confirms or edits the list.
3. **Input:** "Excel input, or no input file?" Skip this question when the scan already answered it.
Default the title and subtitle from the project name, for example `Run automation` and `Upload the input Excel and start <project>`. Don't ask about them.
## 3. Build
1. Copy `run_tab.html`, `run_tab.py`, `runner.py` and `fake_bot.py` into `<project>/run_tab/`.
2. Copy `rt.py` there and next to the bot's entry file.
3. Fill the **CONFIG** block at the top of `run_tab/run_tab.py`:
- `TITLE`, `SUBTITLE`
- `BOT`: `"bot.py"` relative to the project root, or `"package.module:function"`
- `HAS_INPUT`, `INPUT_HINT`
- `CHECKPOINTS`
- `SAMPLE_SHEETS`
- `KILL_ALSO`
- `APP_ID`: the project name, no spaces
4. Apply the theme: `python <skill>/templates/palette.py <sky|teal|#hex> --write <project>/run_tab/run_tab.html`.
- It must print PASS for every row.
- If a row fails, tell the user and suggest a darker hex.
5. Make sure `openpyxl`, `PyQt6` and `PyQt6-WebEngine` are in the project's requirements.
## 4. Wire the bot
Keep the bot's logic as it is. Only add these lines:
```python
import rt
def main(excel_path): # BOT = "bot:main"; the Run tab passes the Excel path
rt.checkpoint(1); rt.ui("Signing in"); ...; rt.ui("Signed in", "ok"); rt.checkpoint_done(1)
failed = []
rt.checkpoint(2)
for i, row in enumerate(rows, start=2):
try: process(row)
except Exception as e: failed.append((f"Row {i}", str(e))); rt.ui(f"Row {i} failed", "warn")
rt.checkpoint_done(2)
return rt.result(total=len(rows), failed=failed)
```
- **Checkpoint numbers** are 1-based positions in `CHECKPOINTS`. A loop may go back to an earlier checkpoint for the next item; the later boxes reset and the bar never moves back.
- **Returning a summary.** A plain script with no entry function prints `rt.summary(total, failed)` as its last line instead. If no summary arrives, the run counts as **failed**.
- **What the operator sees.** Only `[UI]` lines and error lines reach the screen. Everything else goes only to `logs/run_<time>.log`.
- Keep `[UI]` text to progress words and row numbers.
- **Never put** patient or customer names, amounts, account numbers, passwords or other PHI or secrets on screen, or in the failed reasons.
- **Bot output.** Leave the bot's existing prints. They go to the run log.
### 4a. Its own window
There is nothing more to do. Start it with `python run_tab/run_tab.py`.
### 4b. A tab in an existing PyQt page
Copy the four fenced parts of `run_tab.html` into the host page:
- `RUN-TAB:CSS`, `RUN-TAB:TOPBAR`, `RUN-TAB:MARKUP`, `RUN-TAB:JS`
- the `THEME` block, if the host has no tokens of its own
Then wire the host:
- **Page config:** add `const CFG = ...` to the host page; `page_html()` in `run_tab.py` shows its shape.
- **Messages:** route the host's Python→page messages through one `window.__pyMsg`, and merge the `handlePyMessage` branches.
- **Bridge:** give the host's bridge the `Bridge` slots from `run_tab.py`. They are `browse_file`, `process_dropped_path`, `read_sheet`, `download_sample_input`, `start_automation` and `set_dark`.
- **Window:** add the close guard from `RunTabWindow.closeEvent`.
- **Don't connect `js_message` in the page** as well as using `__pyMsg`. Doing both shows every message twice.
## 5. Verify with the fake bot
```
python run_tab/run_tab.py --selfcheck
```
It runs the fake bot through the real runner four times:
- complete → green
- partial → amber
- stuck → red, with box 2 marked stuck
- crash → red
Each line must print `ok`. It then saves the window as `logs/selfcheck_complete_light.png`, `logs/selfcheck_partial_dark.png` and `logs/selfcheck_stuck_light.png`. **Look at all three.** Check:
- the Tebra layout
- your colours, readable in both themes
- the checkpoint names
- no horizontal scroll
**Never press Trigger on the real bot yourself.** Tell the user to start the first real run.
## 6. Hand over (two lines)
Tell the user:
- how to start it (`python run_tab/run_tab.py`)
- where raw run logs go (`logs/`)
If the project ships as a PyInstaller exe:
- the entry point is `run_tab/run_tab.py`
- add `run_tab/run_tab.html` as data
- add the bot module as a hidden import, because the exe starts itself with `--run-bot`
@@ -0,0 +1,11 @@
# One checkpoint list drives the whole Run tab
Tebra Auto Post keeps two overlapping lists:
- 8 **steps**, which move forward when known phrases appear in the log (`STEP_MARKERS`).
- 7 **checkpoints**, which move forward on `[CPSTATE]` lines and have time limits.
The Run tab skill keeps one checkpoint list. The bot's `[CPSTATE] active|done N` lines move the step list, the checkpoint boxes and the progress % together. The screen still looks like Tebra.
**Why:** there is one list to ask the user for, so the skill fits in 5 minutes. And a typo in a log phrase can no longer stall the progress bar without anyone noticing.
**Cost:** the progress bar moves in whole checkpoints, not Tebra's finer per-phrase percentages.
@@ -0,0 +1,11 @@
# The bot talks to the Run tab through tagged lines in a log file
The bot runs as a **separate process** with no Qt. The Run tab starts it with `--log <file>` and reads that file every 0.2 s. It does not read stdout. The bot prints:
- `[CPSTATE] active N` / `[CPSTATE] done N`: checkpoint progress (see ADR-0001).
- `[UI] text`, `[UI:ok]`, `[UI:warn]`, `[UI:error]`: lines the operator sees. Error lines and tracebacks always show, even without a tag. Every raw line stays in the run log file.
- `__RUN_SUMMARY__{json}` as its last line. The outcome comes from this line, not from the exit code. If the line is missing, the run counts as failed, so a crash never shows green.
**Why:** this comes from Tebra Auto Post. UI automation inside a Qt worker thread was unreliable (bug-094). A windowed PyInstaller child's stdout pipe is block-buffered, so log lines arrived in late bursts; reading the log file fixed that. Tagging lines saves each project from writing a table of phrase→friendly-text rules.
**Conflict:** `rpa-dashboard-no-ai/templates/bot_runner.py` reads `stdout=PIPE`, which is the approach Tebra dropped. Fix it there, or point it at the Run tab skill's runner.
@@ -0,0 +1,7 @@
# A stuck run is killed and fails; no auto-restart, no Stop button
When a checkpoint passes its time limit, the Run tab kills the bot's whole process tree and ends the run as failed: "Stuck at <checkpoint>, run again". It does not restart. There is no Stop or Pause button. A run also ends early when the operator closes the window, which asks first and then kills the process tree.
**Why:** Tebra Auto Post restarts a stuck bot up to 3 times. That is only safe because its SQL batch table lets a restarted bot skip work it already did. The Run tab skill has no DB (decision Q2), so a restart could do work twice, for example post a payment twice. Tebra removed Stop on purpose ("a run always goes to completion"), and the Run tab keeps the same look.
**Reopen if:** a project's bot is rerun-safe (it skips items already done). Then auto-restart ×3 can come back as an option.
+31
View File
@@ -0,0 +1,31 @@
"""Fake bot for checking the Run tab. Prints the same protocol lines a real bot does (ADR-0002).
FAKE_MODE: complete | partial | stuck | crash FAKE_CPS: how many checkpoints
Never touches anything outside its own log.
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import rt # noqa: E402
mode = os.environ.get("FAKE_MODE", "complete")
cps = int(os.environ.get("FAKE_CPS", "3"))
print("fake bot starting (this line stays in the log file only)")
for n in range(1, cps + 1):
rt.checkpoint(n)
rt.ui(f"Working on checkpoint {n}")
if mode == "stuck" and n == 2:
time.sleep(3600) # the Run tab must kill this
if mode == "crash" and n == 2:
raise RuntimeError("Simulated crash")
time.sleep(0.4)
rt.ui(f"Checkpoint {n} finished", "ok")
rt.checkpoint_done(n)
if mode == "partial":
rt.ui("2 rows could not be processed", "warn")
rt.summary(5, [("Row 4", "Account not found"), ("Row 5", "Timed out")])
else:
rt.summary(5)
+99
View File
@@ -0,0 +1,99 @@
"""Theme the Run tab: a preset or one brand hex -> the THEME block of run_tab.html.
python palette.py sky|teal|"#7a3cff" print the block and a contrast table
python palette.py "#7a3cff" --write run_tab/run_tab.html also write it into the page
Only the sky gradient and the accent change. ok/warn/bad and the log colours stay fixed (R9).
A brand hex that is too light for white button text is darkened until it passes.
Exits 1 if a contrast check still fails.
"""
import re
import sys
WHITE, BLACK = "#ffffff", "#000000"
INK = {"light": "#1c2b33", "dark": "#e6eef4"}
PRESETS = {
"sky": {"light": {"--sky-top": "#8fc9e8", "--sky-mid": "#bfe3f2", "--sky-bottom": "#eaf6fb",
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#16202a"},
"dark": {"--sky-top": "#0f1b26", "--sky-mid": "#13212e", "--sky-bottom": "#182a38",
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#2e5d80"}},
"teal": {"light": {"--sky-top": "#8fe8ad", "--sky-mid": "#bff2d0", "--sky-bottom": "#eafbf0",
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#16202a"},
"dark": {"--sky-top": "#0f2617", "--sky-mid": "#132e1c", "--sky-bottom": "#183823",
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#2e5d80"}},
}
def rgb(h):
return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
def mix(a, b, t):
"""Blend hex colour a toward b by t (0..1)."""
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
def contrast(a, b):
"""WCAG 2 contrast ratio between two hex colours."""
def lum(h):
c = [v / 255 for v in rgb(h)]
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
hi, lo = sorted((lum(a), lum(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
def derive(brand):
brand = brand.strip().lower()
if len(brand) == 4: # #abc -> #aabbcc
brand = "#" + "".join(ch * 2 for ch in brand[1:])
accent = brand
for _ in range(60): # white text on the Trigger button must stay readable
if contrast(WHITE, accent) >= 3:
break
accent = mix(accent, BLACK, 0.06)
base = {"--accent": accent, "--accent-lite": mix(accent, WHITE, 0.2)}
return {
"light": {"--sky-top": mix(accent, WHITE, 0.55), "--sky-mid": mix(accent, WHITE, 0.72),
"--sky-bottom": mix(accent, WHITE, 0.9), **base, "--accent-dark": mix(accent, "#0b1218", 0.85)},
"dark": {"--sky-top": mix(accent, BLACK, 0.85), "--sky-mid": mix(accent, BLACK, 0.82),
"--sky-bottom": mix(accent, BLACK, 0.78), **base, "--accent-dark": mix(accent, BLACK, 0.4)},
}
def checks(t):
rows = []
for mode, m in t.items():
rows += [(mode, "white text on the Trigger button", contrast(WHITE, m["--accent"]), 3),
(mode, "ink on the top of the sky", contrast(INK[mode], m["--sky-top"]), 4.5),
(mode, "ink on the bottom of the sky", contrast(INK[mode], m["--sky-bottom"]), 4.5)]
rows.append(("light", "progress % on the sky", contrast(t["light"]["--accent-dark"], t["light"]["--sky-bottom"]), 4.5))
return rows
def block(t):
def line(m):
r = ",".join(str(v) for v in rgb(m["--accent"]))
return "; ".join(f"{k}:{v}" for k, v in m.items()) + f"; --accent-rgb:{r};"
return ("/* THEME:START (palette.py --write replaces everything down to THEME:END) */\n"
f":root{{\n {line(t['light'])}\n}}\nbody.dark{{\n {line(t['dark'])}\n}}\n/* THEME:END */")
if __name__ == "__main__":
if len(sys.argv) < 2 or not (sys.argv[1] in PRESETS or sys.argv[1].startswith("#")):
sys.exit(__doc__)
t = PRESETS.get(sys.argv[1]) or derive(sys.argv[1])
css = block(t)
print(css + "\n")
rows = checks(t)
for mode, what, ratio, need in rows:
print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}")
if "--write" in sys.argv:
page = sys.argv[sys.argv.index("--write") + 1]
html = open(page, encoding="utf-8").read()
new, n = re.subn(r"/\* THEME:START.*?THEME:END \*/", lambda _: css, html, flags=re.S)
if n != 1:
sys.exit(f"THEME:START/END markers not found in {page}")
open(page, "w", encoding="utf-8").write(new)
print(f"\nwrote the theme into {page}")
sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)
+36
View File
@@ -0,0 +1,36 @@
"""Run tab protocol for the bot (ADR-0002). Copy this file next to the bot.
import rt
rt.checkpoint(1) # checkpoint 1 starts (its time limit starts counting)
rt.ui("Signed in", "ok") # a line the operator sees: kind "", "ok", "warn" or "error"
rt.checkpoint_done(1)
...
return rt.result(total=40, failed=[("Row 7", "Account not found")])
The bot's entry function returns rt.result(...), and the Run tab prints the summary line.
A bot that is a plain script (no entry function) calls rt.summary(...) as its last line.
"""
import json
SUMMARY = "__RUN_SUMMARY__"
def checkpoint(n):
print(f"[CPSTATE] active {n}", flush=True)
def checkpoint_done(n):
print(f"[CPSTATE] done {n}", flush=True)
def ui(msg, kind=""):
print(f"[UI{':' + kind if kind else ''}] {msg}", flush=True)
def result(total, failed=()):
failed = [[str(item), str(reason)] for item, reason in failed]
return {"total": total, "ok": total - len(failed), "failed": failed}
def summary(total, failed=()):
print(SUMMARY + json.dumps(result(total, failed)), flush=True)
+592
View File
@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* THEME:START (palette.py --write replaces everything down to THEME:END) */
:root{
--sky-top:#8fc9e8; --sky-mid:#bfe3f2; --sky-bottom:#eaf6fb;
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#16202a;
}
body.dark{
--sky-top:#0f1b26; --sky-mid:#13212e; --sky-bottom:#182a38;
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#2e5d80;
}
/* THEME:END */
:root{
--glass:rgba(255,255,255,0.60); --glass-strong:rgba(255,255,255,0.78);
--border:rgba(255,255,255,0.9);
--ink:#1c2b33; --muted:#5c7382;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd;
--bad:#c0392b; --bad-bg:#fdecea;
}
body.dark{
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85);
--border:rgba(120,160,190,.22);
--ink:#e6eef4; --muted:#8fa6b4;
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16);
--warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
color:var(--ink);overflow:hidden;
background:linear-gradient(180deg,var(--sky-top) 0%,var(--sky-mid) 45%,var(--sky-bottom) 100%)}
.hidden{display:none!important}
input[type=file]{display:none}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
/* RUN-TAB:CSS:START */
.main{height:100vh;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:linear-gradient(180deg,rgba(255,255,255,.82),rgba(255,255,255,.55));
border-bottom:1px solid var(--border);backdrop-filter:blur(14px);
padding:20px 28px;display:flex;align-items:center;gap:14px;flex-shrink:0;flex-wrap:wrap;
box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800;color:var(--ink)}
.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.top-run-btn{display:flex;align-items:center;gap:8px;padding:10px 20px;border-radius:11px;border:none;
font-size:13px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}
.top-run-btn svg{width:16px;height:16px;stroke:currentColor}
.top-run-btn.off{background:rgba(255,255,255,.55);color:var(--muted);cursor:not-allowed}
.top-run-btn.on{background:linear-gradient(180deg,var(--accent-lite),var(--accent));color:#fff;
box-shadow:0 2px 6px rgba(var(--accent-rgb),.35)}
.top-run-btn.on:hover{box-shadow:0 6px 16px rgba(var(--accent-rgb),.4);transform:translateY(-1px)}
.top-run-btn.on:active{transform:scale(.97);box-shadow:0 1px 3px rgba(var(--accent-rgb),.3)}
.top-run-btn.going{background:var(--warn-bg);color:var(--warn);cursor:not-allowed}
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);
background:var(--glass-strong);cursor:pointer;font-size:16px;line-height:1;
display:flex;align-items:center;justify-content:center;transition:all .18s ease;
box-shadow:0 1px 3px rgba(20,50,70,.12)}
.mode-btn:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.2)}
.mode-btn:active{transform:scale(.94)}
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border)}
.dot{width:8px;height:8px;border-radius:50%;transition:all .3s}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.partial{background:var(--warn);box-shadow:0 0 7px var(--warn)}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
.ind-text{font-size:12px;font-weight:700;color:var(--ink)}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px;
background:linear-gradient(180deg,rgba(255,255,255,.18),rgba(255,255,255,0));
box-shadow:inset 2px 2px 0 rgba(255,255,255,.35)}
.content::-webkit-scrollbar{width:8px}
.content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.8);border-radius:8px}
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;
backdrop-filter:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.card-head{display:flex;align-items:center;gap:12px;margin-bottom:18px}
.c-icon{width:40px;height:40px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0;
background:var(--glass-strong)}
.c-icon svg{width:20px;height:20px;stroke:var(--accent-dark)}
.c-title{font-size:15px;font-weight:700;color:var(--ink)}
.c-sub{font-size:12px;color:var(--muted);margin-top:2px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid rgba(var(--accent-rgb),.45);
background:linear-gradient(180deg,rgba(var(--accent-rgb),.16),rgba(var(--accent-rgb),.32));
color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;
transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.14)}
.btn:hover{background:linear-gradient(180deg,rgba(var(--accent-rgb),.24),rgba(var(--accent-rgb),.42));transform:translateY(-1px);
box-shadow:0 4px 12px rgba(var(--accent-rgb),.28)}
.btn:active{transform:translateY(0) scale(.97);box-shadow:0 1px 2px rgba(20,50,70,.12)}
.drop-zone{border:2px dashed rgba(var(--accent-rgb),.45);border-radius:14px;padding:36px 20px;text-align:center;cursor:pointer;transition:all .2s;background:rgba(255,255,255,.35)}
.drop-zone:hover,.drop-zone.over{border-color:var(--accent-dark);background:rgba(255,255,255,.6)}
.drop-icon{width:44px;height:44px;stroke:rgba(var(--accent-rgb),.8);margin:0 auto 12px;display:block}
.drop-zone:hover .drop-icon,.drop-zone.over .drop-icon{stroke:var(--accent-dark)}
.drop-title{font-size:15px;font-weight:700;color:var(--ink);margin-bottom:5px}
.drop-sub{font-size:12.5px;color:var(--muted)}
.browse-link{color:var(--accent-dark);cursor:pointer;font-weight:700;text-decoration:underline}
.tags{display:flex;gap:7px;justify-content:center;margin-top:14px;flex-wrap:wrap}
.tag{font-size:11px;color:var(--muted);background:rgba(255,255,255,.6);border:1px solid var(--border);padding:4px 11px;border-radius:6px;font-weight:600}
.file-row{display:flex;align-items:center;gap:14px;padding:15px 16px;background:var(--ok-bg);border-radius:12px;border:1px solid #bfe6cc;margin-top:14px}
.f-details{flex:1;min-width:0}
.f-name{font-size:13.5px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.f-path{font-size:11px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.f-badge{display:inline-flex;align-items:center;gap:5px;margin-top:5px;font-size:11px;color:var(--ok);font-weight:700}
.f-badge::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ok);display:inline-block}
.f-remove{color:var(--muted);cursor:pointer;font-size:20px;padding:3px 9px;border-radius:8px;flex-shrink:0}
.f-remove:hover{color:var(--bad);background:var(--bad-bg)}
.sheet-tabs{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
.sheet-btn{padding:9px 15px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.5);color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.08)}
.sheet-btn:hover{background:rgba(255,255,255,.8);transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.15)}
.sheet-btn:active{transform:scale(.97)}
.sheet-btn.active{background:linear-gradient(180deg,var(--accent-lite),var(--accent));border-color:var(--accent);color:#fff}
.sheet-preview{margin-top:14px;display:none}
.sheet-preview.show{display:block}
.preview-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.preview-title{font-size:13px;font-weight:700;color:var(--ink)}
.preview-count{font-size:11px;color:var(--muted);font-weight:600}
.tbl-wrap{border:1px solid var(--border);border-radius:12px;overflow:auto;max-height:280px;background:rgba(255,255,255,.5)}
.tbl-wrap::-webkit-scrollbar{width:8px;height:8px}
.tbl-wrap::-webkit-scrollbar-thumb{background:rgba(var(--accent-rgb),.3);border-radius:8px}
table.data-tbl{width:100%;border-collapse:collapse;font-size:12px}
table.data-tbl th{background:rgba(255,255,255,.85);color:var(--ink);font-weight:700;text-align:left;padding:9px 12px;position:sticky;top:0;border-bottom:1px solid var(--border);white-space:nowrap}
table.data-tbl td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.7);color:#2b3f4a;white-space:nowrap}
table.data-tbl .mask{color:#9fb0b8;letter-spacing:2px}
.preview-empty{padding:24px;text-align:center;color:var(--muted);font-size:12px}
.clear-bar{display:none;align-items:center;gap:12px;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.7)}
.clear-bar.show{display:flex}
.clear-btn{padding:9px 16px;border-radius:10px;border:1px solid #f0c6c0;background:var(--bad-bg);color:var(--bad);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .18s ease;box-shadow:0 1px 3px rgba(192,57,43,.12)}
.clear-btn:hover{background:var(--bad);color:#fff;transform:translateY(-1px);box-shadow:0 4px 10px rgba(192,57,43,.25)}
.clear-btn:active{transform:scale(.97)}
.clear-hint{font-size:11.5px;color:var(--muted)}
.prog-wrap{display:none;margin-bottom:6px}
.prog-wrap.show{display:block}
.prog-head{display:flex;align-items:center;gap:12px;margin-bottom:8px}
.prog-lbl{font-size:13px;font-weight:700;color:var(--ink);margin-right:auto}
.prog-time{font-size:12.5px;font-weight:700;color:var(--muted);
background:rgba(var(--accent-rgb),.12);padding:2px 9px;border-radius:8px;white-space:nowrap}
.prog-pct{font-size:13px;color:var(--accent-dark);font-weight:800}
.prog-bg{height:8px;background:rgba(255,255,255,.6);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;background:linear-gradient(90deg,#28c26f,#1f9d57);border-radius:8px;width:0%;transition:width .5s ease}
.prog-fill.partial{background:linear-gradient(90deg,#e0a53c,#b7791f)}
.prog-fill.failed{background:linear-gradient(90deg,#e8695b,#c0392b)}
.steps{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;border:1px solid transparent}
.step.done{background:var(--ok-bg);border-color:#cdeed8}
.step.active{background:var(--warn-bg);border-color:#f0dcae}
.step.stuck{background:var(--bad-bg);border-color:#f0b6b0}
.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;font-weight:700}
.step.done .step-dot{background:var(--ok);color:#fff}
.step.active .step-dot{background:var(--warn);color:#fff}
.step.stuck .step-dot{background:var(--bad);color:#fff}
.step.pending .step-dot{background:rgba(255,255,255,.7);color:var(--muted)}
.step-lbl{font-size:13px;font-weight:600}
.step.done .step-lbl{color:var(--ok)}
.step.active .step-lbl{color:var(--warn)}
.step.stuck .step-lbl{color:var(--bad)}
.step.pending .step-lbl{color:var(--muted)}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:200px;overflow-y:auto;
font-family:'Consolas','Courier New',monospace;font-size:13.5px;line-height:1.85}
.log::-webkit-scrollbar{width:6px}
.log::-webkit-scrollbar-thumb{background:#3a4a55;border-radius:6px}
.log-line{color:#9fb6c4;overflow-wrap:anywhere}
.log-line.info{color:#7fc4f0}
.log-line.success{color:#5fe0a0}
.log-line.warn{color:#f5c563}
.log-line.error{color:#f68c82}
.cp-wrap{margin-top:14px}
#cp-elapsed{margin-left:auto;font-weight:700;color:var(--accent-dark)}
.cp-title{font-size:12px;font-weight:800;letter-spacing:.4px;text-transform:uppercase;
color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:8px}
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px}
.cp-box{position:relative;border-radius:11px;padding:11px 10px 10px;
border:1.5px solid rgba(var(--accent-rgb),.20);background:rgba(var(--accent-rgb),.06);
display:flex;flex-direction:column;gap:5px;min-height:74px;transition:all .25s}
.cp-box .cp-num{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:11px;font-weight:800;flex-shrink:0;
background:rgba(255,255,255,.6);color:var(--muted)}
.cp-box .cp-name{font-size:12px;font-weight:700;color:var(--ink);line-height:1.25}
.cp-box .cp-meta{font-size:10.5px;font-weight:700;color:var(--muted);margin-top:auto}
.cp-box.pending{opacity:.55}
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);box-shadow:0 0 0 3px rgba(245,197,99,.14)}
.cp-box.active .cp-num{background:var(--warn);color:#fff;animation:cp-pulse 1.2s ease-in-out infinite}
.cp-box.active .cp-meta{color:var(--warn)}
.cp-box.done{border-color:#cdeed8;background:var(--ok-bg)}
.cp-box.done .cp-num{background:var(--ok);color:#fff}
.cp-box.done .cp-meta{color:var(--ok)}
.cp-box.stuck{border-color:#f0b6b0;background:var(--bad-bg)}
.cp-box.stuck .cp-num{background:var(--bad);color:#fff}
.cp-box.stuck .cp-meta{color:var(--bad)}
@keyframes cp-pulse{0%,100%{box-shadow:0 0 0 0 rgba(245,197,99,.5)}50%{box-shadow:0 0 0 6px rgba(245,197,99,0)}}
body.dark .topbar{background:linear-gradient(180deg,rgba(30,45,58,.92),rgba(22,34,44,.7));box-shadow:0 6px 18px rgba(0,0,0,.28)}
body.dark .content{background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,0));box-shadow:inset 2px 2px 0 rgba(255,255,255,.08)}
body.dark .content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18)}
body.dark .btn{background:linear-gradient(180deg,rgba(var(--accent-rgb),.22),rgba(var(--accent-rgb),.12));color:var(--ink)}
body.dark .drop-zone{background:rgba(255,255,255,.04);border-color:rgba(var(--accent-rgb),.35)}
body.dark .drop-zone:hover,body.dark .drop-zone.over{background:rgba(255,255,255,.08);border-color:var(--accent-lite)}
body.dark .tag{background:rgba(255,255,255,.07)}
body.dark .top-run-btn.off{background:rgba(255,255,255,.08)}
body.dark .file-row{background:var(--ok-bg);border-color:rgba(47,191,114,.35)}
body.dark .sheet-btn{background:rgba(255,255,255,.07)}
body.dark .sheet-btn:hover{background:rgba(255,255,255,.14)}
body.dark .tbl-wrap{background:rgba(255,255,255,.03)}
body.dark table.data-tbl th{background:#1d2f3c;color:var(--ink)}
body.dark table.data-tbl td{color:#c9d8e2;border-bottom:1px solid rgba(255,255,255,.06)}
body.dark .clear-bar{border-top-color:rgba(120,160,190,.15)}
body.dark .clear-btn{border-color:rgba(224,90,75,.4)}
body.dark .prog-bg{background:rgba(255,255,255,.12)}
body.dark .step.pending .step-dot{background:rgba(255,255,255,.12)}
body.dark .step.done{border-color:rgba(47,191,114,.3)}
body.dark .step.active{border-color:rgba(217,161,60,.3)}
body.dark .step.stuck{border-color:rgba(224,90,75,.35)}
body.dark .log{background:#0d161e}
body.dark .prog-pct,body.dark .browse-link,body.dark #cp-elapsed{color:var(--accent-lite)}
body.dark .c-icon svg{stroke:var(--accent-lite)}
body.dark .cp-box .cp-name{color:#cfe0ec}
body.dark .cp-box.pending .cp-num{background:rgba(255,255,255,.12)}
body.dark .cp-box.done{border-color:rgba(47,191,114,.3)}
body.dark .cp-box.stuck{border-color:rgba(224,90,75,.35)}
@media (max-width:820px){
.topbar{padding:16px}
.content{padding:18px 16px}
}
/* RUN-TAB:CSS:END */
</style>
</head>
<body>
<div class="main">
<!-- RUN-TAB:TOPBAR:START -->
<div class="topbar">
<div>
<div class="tb-title" id="tb-title">Run automation</div>
<div class="tb-sub" id="tb-sub"></div>
</div>
<div class="tb-right">
<button class="top-run-btn off" id="top-run-btn" onclick="doRun()">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span id="top-run-lbl">Upload a file to continue</span>
</button>
<button class="mode-btn" id="mode-btn" onclick="toggleDark()" title="Toggle dark mode">🌙</button>
<div class="indicator">
<div class="dot ready" id="sys-dot"></div>
<div class="ind-text" id="sys-txt">System ready</div>
</div>
</div>
</div>
<!-- RUN-TAB:TOPBAR:END -->
<div class="content">
<!-- RUN-TAB:MARKUP:START -->
<div id="tab-run">
<div id="input-card">
<div class="section-label">Input configuration</div>
<div class="card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div>
<div class="c-title">Input Excel upload</div>
<div class="c-sub" id="input-hint"></div>
</div>
<button class="btn" id="sample-btn" style="margin-left:auto;flex-shrink:0"
onclick="pyBridge&&pyBridge.download_sample_input()"
title="Save an example workbook with the columns this bot reads">&#11015; Sample input</button>
</div>
<div class="drop-zone" id="dz" ondragover="dzOver(event)" ondragleave="dzLeave()" ondrop="dzDrop(event)"
onclick="pyBridge&&pyBridge.browse_file()">
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="drop-title">Drop your Excel file here</div>
<div class="drop-sub">or <span class="browse-link" onclick="event.stopPropagation();pyBridge&&pyBridge.browse_file()">browse files</span></div>
<div class="tags"><span class="tag">.xlsx</span><span class="tag">.xlsm</span><span class="tag">.xls</span></div>
</div>
<input type="file" id="fi" accept=".xlsx,.xlsm,.xls" onchange="fileChosen(this.files[0])">
<div class="file-row hidden" id="file-row">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div class="f-details">
<div class="f-name" id="f-name">—</div>
<div class="f-path" id="f-path">—</div>
<div class="f-badge">Ready to process</div>
</div>
<div class="f-remove" onclick="removeFile()" title="Remove">&times;</div>
</div>
<div class="sheet-tabs hidden" id="sheet-tabs"></div>
<div class="sheet-preview" id="sheet-preview">
<div class="preview-head">
<div class="preview-title" id="preview-title">Sheet</div>
<div class="preview-count" id="preview-count"></div>
</div>
<div class="tbl-wrap" id="tbl-wrap"></div>
</div>
<div class="clear-bar" id="clear-bar">
<button class="clear-btn" onclick="removeFile()">Clear file</button>
<span class="clear-hint">Uploaded the wrong file? Clear and choose again.</span>
</div>
</div>
</div>
<div class="card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
<div>
<div class="c-title">Run automation</div>
<div class="c-sub" id="run-flow"></div>
</div>
</div>
<div class="prog-wrap" id="prog-wrap">
<div class="prog-head">
<span class="prog-lbl" id="prog-lbl">Starting...</span>
<span class="prog-time" id="prog-time"></span>
<span class="prog-pct" id="prog-pct">0%</span>
</div>
<div class="prog-bg"><div class="prog-fill" id="prog-fill"></div></div>
<div class="steps" id="steps"></div>
<div class="log" id="log"></div>
<div class="cp-wrap">
<div class="cp-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Progress checkpoints
<span id="cp-elapsed"></span>
</div>
<div class="cp-track" id="cp-track"></div>
</div>
</div>
</div>
</div>
<!-- RUN-TAB:MARKUP:END -->
</div>
</div>
<script>
/* Filled by run_tab.py from its config: {title, sub, input, inputHint, sample, darkKey,
checkpoints:[{name, max}]}. max = seconds before the checkpoint counts as stuck, 0 = no limit. */
const CFG = %%CONFIG%%;
let pyBridge=null;
/* RUN-TAB:JS:START */
let fileReady=false, filePath='', running=false;
const CPS=CFG.checkpoints;
function escapeHtml(s){ const d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
/* Python -> page. Messages arrive ONLY through window.__pyMsg (run_tab.py calls it).
Never also connect js_message in QWebChannel: Tebra did both and every line showed twice. */
window.__pyMsg = function(s){ const m=JSON.parse(s); handlePyMessage(m.event,m.data); };
function handlePyMessage(event,data){
if(event==='file_selected') setFile(data.name,data.path,data.sheets);
else if(event==='sheet_data') renderSheetData(data);
else if(event==='log') addLog(data.msg,data.type);
else if(event==='cpstate') cpState(data.kind, data.cp);
else if(event==='status') onStatus(data.state, data.msg);
else if(event==='apply_dark') applyDark(data.on, true);
}
function initRunTab(){
document.getElementById('tb-title').textContent=CFG.title;
document.getElementById('tb-sub').textContent=CFG.sub;
document.getElementById('run-flow').textContent=CPS.map(c=>c.name).join(' → ');
document.getElementById('input-hint').textContent=CFG.inputHint||'';
document.getElementById('input-card').classList.toggle('hidden', !CFG.input);
document.getElementById('sample-btn').classList.toggle('hidden', !CFG.sample);
fileReady=!CFG.input;
renderCpTrack(); renderSteps(); updateRunBtn();
}
/* ---- dark mode: saved in localStorage and, through the bridge, in QSettings ---- */
function applyDark(on, fromPy){
document.body.classList.toggle('dark', !!on);
const b=document.getElementById('mode-btn'); if(b) b.textContent=on?'☀️':'🌙';
try{ localStorage.setItem(CFG.darkKey, on?'1':'0'); }catch(e){}
if(pyBridge && !fromPy) pyBridge.set_dark(!!on);
}
function toggleDark(){ applyDark(!document.body.classList.contains('dark')); }
/* ---- input file ---- */
function dzOver(e){e.preventDefault();document.getElementById('dz').classList.add('over');}
function dzLeave(){document.getElementById('dz').classList.remove('over');}
function dzDrop(e){e.preventDefault();dzLeave();const f=e.dataTransfer.files[0];if(f)fileChosen(f);}
function fileChosen(f){
if(!f||running)return;
const ext='.'+f.name.split('.').pop().toLowerCase();
if(!['.xlsx','.xlsm','.xls'].includes(ext)){addLog('Invalid file type — use .xlsx .xlsm .xls','warn');return;}
// QWebEngine hides the real path of a dropped file, so Python resolves the name.
if(pyBridge) pyBridge.process_dropped_path(f.path || f.name);
else setFile(f.name, f.path || f.name, []);
}
function setFile(name,path,sheets){
if(!path){ addLog('Could not find "'+name+'" on disk — use browse files instead.','warn'); return; }
fileReady=true; filePath=path;
document.getElementById('dz').style.display='none';
document.getElementById('file-row').classList.remove('hidden');
document.getElementById('f-name').textContent=name;
document.getElementById('f-path').textContent=path;
document.getElementById('clear-bar').classList.add('show');
updateRunBtn();
renderSheetButtons(sheets||[]);
}
function renderSheetButtons(sheets){
const tabs=document.getElementById('sheet-tabs');
document.getElementById('sheet-preview').classList.remove('show');
if(!sheets.length){tabs.classList.add('hidden');tabs.innerHTML='';return;}
tabs.classList.remove('hidden');
tabs.innerHTML=sheets.map((s,i)=>`<div class="sheet-btn" id="sb-${i}" data-sheet="${escapeHtml(s)}" onclick="openSheet(this.dataset.sheet,${i})">${escapeHtml(s)}</div>`).join('');
openSheet(sheets[0], 0); // show the first sheet right after upload
}
function openSheet(name,idx){
document.querySelectorAll('.sheet-btn').forEach(b=>b.classList.remove('active'));
const b=document.getElementById('sb-'+idx); if(b)b.classList.add('active');
document.getElementById('tbl-wrap').innerHTML='<div class="preview-empty">Loading...</div>';
document.getElementById('sheet-preview').classList.add('show');
document.getElementById('preview-title').textContent=name;
document.getElementById('preview-count').textContent='';
if(pyBridge)pyBridge.read_sheet(filePath,name);
}
function renderSheetData(data){
const wrap=document.getElementById('tbl-wrap');
if(data.error){wrap.innerHTML='<div class="preview-empty">Error: '+escapeHtml(data.error)+'</div>';return;}
document.getElementById('preview-count').textContent=data.total+' row(s)'+(data.more?' — first '+data.rows.length+' shown':'');
if(!data.header.length){wrap.innerHTML='<div class="preview-empty">Sheet is empty</div>';return;}
const secret=data.header.map(x=>/pass(word)?|pwd|secret|pin\b/i.test(String(x)));
let h='<table class="data-tbl"><thead><tr>';
data.header.forEach(x=>h+='<th>'+escapeHtml(x)+'</th>');
h+='</tr></thead><tbody>';
data.rows.forEach(row=>{
h+='<tr>';
row.forEach((cell,ci)=>{
if(secret[ci] && String(cell)) h+='<td class="mask">'+'•'.repeat(Math.min(String(cell).length,10))+'</td>';
else h+='<td>'+escapeHtml(cell)+'</td>';
});
h+='</tr>';
});
wrap.innerHTML=h+'</tbody></table>';
}
function removeFile(){
if(running) return;
fileReady=!CFG.input; filePath='';
document.getElementById('dz').style.display='';
document.getElementById('file-row').classList.add('hidden');
document.getElementById('fi').value='';
document.getElementById('sheet-tabs').classList.add('hidden');
document.getElementById('sheet-tabs').innerHTML='';
document.getElementById('sheet-preview').classList.remove('show');
document.getElementById('clear-bar').classList.remove('show');
document.getElementById('tbl-wrap').innerHTML='';
updateRunBtn();
}
/* ---- run ---- */
function updateRunBtn(){
const btn=document.getElementById('top-run-btn'),lbl=document.getElementById('top-run-lbl');
if(running){btn.className='top-run-btn going';lbl.textContent='Running...';}
else if(fileReady){btn.className='top-run-btn on';lbl.textContent='Trigger automation';}
else{btn.className='top-run-btn off';lbl.textContent='Upload a file to continue';}
}
function doRun(){
if(!fileReady||running)return;
running=true; maxPct=0;
document.getElementById('prog-wrap').classList.add('show');
document.getElementById('log').innerHTML='';
document.getElementById('prog-fill').className='prog-fill';
setProgress(0,'Starting...');
resetCheckpoints(); startRunTimer(); updateRunBtn();
updateStatus('running','Running automation...');
if(pyBridge)pyBridge.start_automation(filePath);
else addLog('No Python bridge — page preview only','warn');
}
/* ---- elapsed timers ---- */
let runTimer=null, runStart=0;
function fmtElapsed(sec){ if(sec<60) return sec+'s'; return Math.floor(sec/60)+'m '+(sec%60)+'s ('+sec+'s)'; }
function fmtClock(sec){ return String(Math.floor(sec/60)).padStart(2,'0')+':'+String(sec%60).padStart(2,'0'); }
function tickRunTimer(){
const secs=Math.round((Date.now()-runStart)/1000);
document.getElementById('prog-time').textContent='⏱ '+fmtElapsed(secs);
document.getElementById('cp-elapsed').textContent='⏱ '+fmtClock(secs);
tickCpTimer();
}
function startRunTimer(){ runStart=Date.now(); if(runTimer) clearInterval(runTimer); tickRunTimer(); runTimer=setInterval(tickRunTimer,1000); }
function stopRunTimer(prefix){
if(runTimer){ clearInterval(runTimer); runTimer=null; }
if(runStart) document.getElementById('prog-time').textContent=prefix+fmtElapsed(Math.round((Date.now()-runStart)/1000));
}
/* ---- checkpoints: one list drives the boxes, the step list and the progress % (ADR-0001) ---- */
let cpActive=0, cpActiveStart=0, maxPct=0;
const cpStates=()=>CPS.map((_,i)=>{ const b=cpBox(i+1); return b?b.className.replace('cp-box ',''):'pending'; });
function fmtMax(sec){ if(!sec) return 'no limit'; return 'max '+(sec%60===0 ? (sec/60)+' min' : sec+'s'); }
function renderCpTrack(){
document.getElementById('cp-track').innerHTML=CPS.map((d,i)=>
'<div class="cp-box pending" id="cpbox-'+(i+1)+'">'+
'<div class="cp-num">'+(i+1)+'</div>'+
'<div class="cp-name">'+escapeHtml(d.name)+'</div>'+
'<div class="cp-meta" id="cpmeta-'+(i+1)+'">'+fmtMax(d.max)+'</div>'+
'</div>').join('');
}
function resetCheckpoints(){ cpActive=0; cpActiveStart=0; renderCpTrack(); renderSteps(); document.getElementById('cp-elapsed').textContent=''; }
function cpBox(n){ return document.getElementById('cpbox-'+n); }
function setCpMeta(n,txt){ const m=document.getElementById('cpmeta-'+n); if(m) m.textContent=txt; }
function cpState(kind,n){
if(!n||n>CPS.length) return;
if(kind==='active'){
// below n = done, n = active, above n = pending (a loop back to an earlier checkpoint resets the later ones)
CPS.forEach((d,i)=>{
const k=i+1, b=cpBox(k);
if(k<n){ b.className='cp-box done'; setCpMeta(k,'✓ done'); }
else if(k===n){ b.className='cp-box active'; }
else { b.className='cp-box pending'; setCpMeta(k,fmtMax(d.max)); }
});
cpActive=n; cpActiveStart=Date.now(); tickCpTimer();
setProgress(Math.round(100*(n-1)/CPS.length), CPS[n-1].name+'...');
} else if(kind==='done'){
cpBox(n).className='cp-box done'; setCpMeta(n,'✓ done');
if(cpActive===n) cpActive=0;
setProgress(Math.round(100*n/CPS.length), CPS[n-1].name+' done');
} else if(kind==='stuck'){
cpBox(n).className='cp-box stuck'; setCpMeta(n,'⚠ stuck — stopped'); cpActive=0;
}
renderSteps();
}
function tickCpTimer(){
if(!cpActive||!cpActiveStart) return;
const secs=Math.round((Date.now()-cpActiveStart)/1000), def=CPS[cpActive-1];
setCpMeta(cpActive, def.max ? fmtClock(secs)+' / '+fmtClock(def.max) : fmtClock(secs));
}
function renderSteps(){
const st=cpStates();
document.getElementById('steps').innerHTML=CPS.map((d,i)=>{
const s=st[i], dot=s==='done'?'✓':s==='stuck'?'!':(i+1);
return `<div class="step ${s}"><div class="step-dot">${dot}</div><div class="step-lbl">${escapeHtml(d.name)}</div></div>`;
}).join('');
}
function setProgress(pct,label){
maxPct=Math.max(maxPct,pct); // the bar only moves forward, even when a loop restarts a checkpoint
document.getElementById('prog-fill').style.width=maxPct+'%';
document.getElementById('prog-pct').textContent=maxPct+'%';
if(label)document.getElementById('prog-lbl').textContent=label;
}
/* ---- log: no timestamps on screen, by design; the raw log file has everything ---- */
function addLog(msg,type){
const box=document.getElementById('log'), last=box.lastElementChild;
if(last && last.textContent===msg) return; // drop consecutive duplicates
const d=document.createElement('div');
d.className='log-line '+(type||'');
d.textContent=msg;
box.appendChild(d); box.scrollTop=box.scrollHeight;
}
function updateStatus(state,msg){
document.getElementById('sys-dot').className='dot '+state;
document.getElementById('sys-txt').textContent=msg;
}
/* ---- endings: green completed / amber partial / red failed (ADR-0003, R11) ---- */
function onStatus(state,msg){
if(state==='running'){ updateStatus('running',msg||'Running automation...'); return; }
running=false; cpActive=0;
const fill=document.getElementById('prog-fill');
if(state==='done'){
CPS.forEach((_,i)=>{ cpBox(i+1).className='cp-box done'; setCpMeta(i+1,'✓ done'); });
renderSteps(); maxPct=0; setProgress(100,'Completed');
stopRunTimer('✓ Completed in '); updateStatus('done',msg||'Completed');
} else if(state==='partial'){
fill.className='prog-fill partial'; maxPct=0; setProgress(100,msg);
stopRunTimer('⚠ Finished in '); updateStatus('partial',msg);
} else {
fill.className='prog-fill failed'; setProgress(maxPct,msg);
stopRunTimer('✕ Failed after '); updateStatus('error',msg); addLog(msg,'error');
}
updateRunBtn();
}
if(window.qt && window.QWebChannel){
new QWebChannel(qt.webChannelTransport, function(channel){ pyBridge = channel.objects.pyBridge; });
}
initRunTab();
(function(){ let on=false; try{ on=localStorage.getItem(CFG.darkKey)==='1'; }catch(e){} if(on) applyDark(true, true); })();
/* RUN-TAB:JS:END */
</script>
</body>
</html>
+345
View File
@@ -0,0 +1,345 @@
"""Run tab: PyQt6 window + bridge for the page in run_tab.html.
python run_tab/run_tab.py open the Run tab
python run_tab/run_tab.py --selfcheck run the fake bot through every ending, save screenshots
The same file is also the bot's process: the window starts `run_tab.py --run-bot ...`
(or `<app>.exe --run-bot ...` when frozen). That branch loads no Qt (ADR-0002).
"""
import json
import os
import sys
import threading
from datetime import datetime
from pathlib import Path
# ---- CONFIG: the rpa-run-tab skill fills these in ------------------------------------
TITLE = "Run automation"
SUBTITLE = "Upload the input Excel and start the bot"
BOT = "bot.py" # "path/to/bot.py" (from the project root) or "package.module:function"
HAS_INPUT = True # False hides the input card; Trigger is ready straight away
INPUT_HINT = "Excel workbook with the input sheets"
CHECKPOINTS = [ # (name, seconds before it counts as stuck; 0 = no limit)
("Sign in", 300),
("Process items", 0),
("Finish up", 300),
]
SAMPLE_SHEETS = {} # {"Sheet name": ["Column", ...]}; empty hides the Sample input button
KILL_ALSO = [] # extra .exe names to kill with the bot, e.g. ["chromedriver.exe"]
APP_ID = "RunTab" # QSettings name and the page's dark-mode key
# ---------------------------------------------------------------------------------------
FROZEN = getattr(sys, "frozen", False)
HERE = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
ROOT = Path(sys.executable).parent if FROZEN else Path(__file__).resolve().parent.parent
LOGS = ROOT / "logs"
def _arg(flag, default=""):
return sys.argv[sys.argv.index(flag) + 1] if flag in sys.argv else default
def _watch_parent(ppid):
"""Exit the bot the moment the window's process dies, so it never runs orphaned."""
try:
if sys.platform.startswith("win"):
import ctypes
k32 = ctypes.windll.kernel32
h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
if h:
k32.WaitForSingleObject(h, 0xFFFFFFFF)
else:
import time
while os.getppid() == ppid:
time.sleep(1)
except Exception:
return # can't watch: keep running
os._exit(1)
def run_bot_child():
"""The bot's process: all output goes to --log, the bot runs, its result becomes the summary line."""
log = open(_arg("--log"), "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = log
threading.Thread(target=_watch_parent, args=(int(_arg("--parent-pid", "0")),), daemon=True).start()
excel, bot = _arg("--excel"), _arg("--bot", BOT)
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
if bot.endswith(".py"):
import runpy
sys.argv = [bot] + ([excel] if excel else [])
sys.path.insert(0, str((ROOT / bot).parent))
runpy.run_path(str(ROOT / bot), run_name="__main__") # a plain script prints its own summary
else:
import importlib
mod, func = bot.split(":")
sys.path.insert(0, str(HERE))
result = getattr(importlib.import_module(mod), func)(*([excel] if excel else []))
if isinstance(result, dict):
print("__RUN_SUMMARY__" + json.dumps(result))
log.flush()
os._exit(0)
if "--run-bot" in sys.argv:
run_bot_child()
# ---- everything below runs only in the window's process --------------------------------
sys.path.insert(0, str(HERE))
from PyQt6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, pyqtSignal, pyqtSlot # noqa: E402
from PyQt6.QtWebChannel import QWebChannel # noqa: E402
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
from PyQt6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox # noqa: E402
import runner # noqa: E402
SEARCH_DIRS = [ROOT, ROOT / "input", Path.home() / "Downloads", Path.home() / "Desktop", Path.home() / "Documents"]
def page_html():
cfg = {"title": TITLE, "sub": SUBTITLE, "input": HAS_INPUT, "inputHint": INPUT_HINT,
"sample": bool(SAMPLE_SHEETS), "darkKey": APP_ID + "_dark",
"checkpoints": [{"name": n, "max": m} for n, m in CHECKPOINTS]}
return (HERE / "run_tab.html").read_text(encoding="utf-8").replace("%%CONFIG%%", json.dumps(cfg))
def bot_cmd(excel, log, bot=BOT):
me = [sys.executable] if FROZEN else [sys.executable, str(Path(__file__).resolve())]
return me + ["--run-bot", "--bot", bot, "--excel", excel or "", "--log", str(log),
"--parent-pid", str(os.getpid())]
def build_sample(path, sheets):
"""Headers-only sample workbook with one fake row. Never real data (R13)."""
from openpyxl import Workbook
wb = Workbook()
wb.remove(wb.active)
for name, cols in sheets.items():
ws = wb.create_sheet(name[:31])
ws.append(cols)
ws.append([f"Example {c}".strip() for c in cols])
wb.save(path)
def read_sheet_rows(path, sheet, cap=200):
"""(header, rows, more): first `cap` non-empty rows as text, Excel date serials as m/d/yyyy."""
from datetime import date, timedelta
from openpyxl import load_workbook
def cell(c):
if c is None:
return ""
if isinstance(c, datetime):
c = c.date()
if isinstance(c, date):
return f"{c.month}/{c.day}/{c.year}"
if isinstance(c, (int, float)) and not isinstance(c, bool) and 36526 <= c <= 73050: # 2000..2099
d = date(1899, 12, 30) + timedelta(days=int(c))
return f"{d.month}/{d.day}/{d.year}"
return str(c)
wb = load_workbook(path, read_only=True, data_only=True)
try:
ws = wb[sheet] if sheet in wb.sheetnames else wb.active
rows, more = [], False
for r in ws.iter_rows(values_only=True):
cells = [cell(c) for c in r]
if not any(x.strip() for x in cells):
continue
if len(rows) >= cap:
more = True
break
rows.append(cells)
finally:
wb.close()
return (rows[0] if rows else []), rows[1:], more
class Bridge(QObject):
"""Exposed to the page as pyBridge."""
js_message = pyqtSignal(str)
def __init__(self, window, bot=BOT, checkpoints=CHECKPOINTS):
super().__init__()
self.window, self.bot, self.checkpoints = window, bot, checkpoints
self.run = None
@property
def running(self):
return self.run is not None and self.run.thread.is_alive()
def send(self, event, data):
self.js_message.emit(json.dumps({"event": event, "data": data}))
def _file(self, path):
sheets = []
try:
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True)
sheets = list(wb.sheetnames)
wb.close()
except Exception as e:
self.send("log", {"msg": f"Could not open the workbook: {e}", "type": "warn"})
self.send("file_selected", {"path": path, "name": os.path.basename(path), "sheets": sheets})
@pyqtSlot()
def browse_file(self):
path, _ = QFileDialog.getOpenFileName(self.window, "Select input Excel file", str(Path.home()),
"Excel files (*.xlsx *.xlsm *.xls)")
if path:
self._file(path)
@pyqtSlot(str)
def process_dropped_path(self, path):
# The page only knows the dropped file's name, so look for it in the usual folders.
if not os.path.isabs(path):
path = next((str(d / path) for d in SEARCH_DIRS if (d / path).is_file()), "")
if path and os.path.isfile(path):
self._file(path)
else:
self.send("file_selected", {"path": "", "name": os.path.basename(path) or "file", "sheets": []})
@pyqtSlot(str, str)
def read_sheet(self, path, sheet):
try:
header, rows, more = read_sheet_rows(path, sheet)
self.send("sheet_data", {"sheet": sheet, "header": header, "rows": rows,
"total": len(rows), "more": more})
except Exception as e:
self.send("sheet_data", {"sheet": sheet, "error": str(e), "header": [], "rows": [], "total": 0})
@pyqtSlot()
def download_sample_input(self):
path, _ = QFileDialog.getSaveFileName(self.window, "Save sample input",
str(Path.home() / f"{APP_ID}_sample_input.xlsx"),
"Excel files (*.xlsx)")
if path:
build_sample(path, SAMPLE_SHEETS)
self.send("log", {"msg": f"Sample saved: {path}", "type": "success"})
@pyqtSlot(str)
def start_automation(self, excel):
if self.running: # double-run guard
return
LOGS.mkdir(exist_ok=True)
log = LOGS / f"run_{datetime.now():%Y%m%d_%H%M%S}.log"
self.run = runner.Run(bot_cmd(excel if HAS_INPUT else "", log, self.bot), log, self.checkpoints,
self.send, KILL_ALSO, cwd=str(ROOT)).start()
@pyqtSlot(bool)
def set_dark(self, on):
QSettings("RPA", APP_ID).setValue("dark", on)
class RunTabWindow(QMainWindow):
def __init__(self, bot=BOT, checkpoints=CHECKPOINTS):
super().__init__()
self.setWindowTitle(TITLE)
self.setMinimumSize(940, 620)
self.resize(1320, 820)
self.view = QWebEngineView()
self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) # no Reload mid-run
self.bridge = Bridge(self, bot, checkpoints)
self.channel = QWebChannel()
self.channel.registerObject("pyBridge", self.bridge)
self.view.page().setWebChannel(self.channel)
self.bridge.js_message.connect(self._push)
self.view.loadFinished.connect(self._loaded)
self.view.setHtml(page_html(), QUrl("qrc:///"))
self.setCentralWidget(self.view)
def _push(self, s):
self.view.page().runJavaScript(f"window.__pyMsg && window.__pyMsg({json.dumps(s)})")
def _loaded(self, ok):
if ok:
on = QSettings("RPA", APP_ID).value("dark", False, type=bool)
self.bridge.send("apply_dark", {"on": on})
def closeEvent(self, event):
if self.bridge.running:
ans = QMessageBox.question(
self, f"Close {TITLE}?",
"The automation is still running.\n\nClosing will STOP it now. Close anyway?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if ans != QMessageBox.StandardButton.Yes:
event.ignore()
return
self.bridge.run.kill()
event.accept()
def selfcheck():
"""Fake bot through every ending with the real runner, then window screenshots of a green
run (light), an amber run (dark) and a stuck run (light), saved to logs/ (R14).
Never runs the real bot."""
import time
fake = str((HERE / "fake_bot.py").relative_to(ROOT))
os.environ["FAKE_CPS"] = str(len(CHECKPOINTS))
quick = [(n, 2 if i == 1 else 0) for i, (n, _) in enumerate(CHECKPOINTS)] # checkpoint 2 sticks after 2 s
want = {"complete": "done", "partial": "partial", "stuck": "error", "crash": "error"}
LOGS.mkdir(exist_ok=True)
for mode, state in want.items():
os.environ["FAKE_MODE"] = mode
events = []
log = LOGS / f"selfcheck_{mode}.log"
r = runner.Run(bot_cmd("", log, fake), log, quick, lambda e, d: events.append((e, d)), cwd=str(ROOT)).start()
r.thread.join(60)
end = events[-1][1] if events and events[-1][0] == "status" else {}
assert end.get("state") == state, f"{mode}: expected {state}, got {end} (log: {log})"
if mode == "stuck":
assert ("cpstate", {"kind": "stuck", "cp": 2}) in events, f"stuck box missing: {events}"
print(f"ok {mode:<8} -> {end['state']}: {end['msg']}")
# The window: one green run in light, one amber run in dark, saved as PNGs to look at.
app = QApplication.instance() or QApplication(sys.argv)
w = RunTabWindow(fake, quick)
w.resize(1280, 900)
w.show()
shots = [("complete", False), ("partial", True), ("stuck", False)]
def wait_for_end(then):
def poll():
if w.bridge.running or w.bridge.run is None:
QTimer.singleShot(200, poll)
else:
QTimer.singleShot(800, then)
poll()
def next_shot():
if not shots:
app.quit()
return
mode, dark = shots.pop(0)
os.environ["FAKE_MODE"] = mode
w.bridge.run = None
w.bridge.send("apply_dark", {"on": dark})
w.view.page().runJavaScript("fileReady=true; filePath=''; doRun();")
def save():
out = LOGS / f"selfcheck_{mode}_{'dark' if dark else 'light'}.png"
w.view.grab().save(str(out))
print(f"ok screenshot {out}")
next_shot()
def scrolled_save():
w.view.page().runJavaScript("document.getElementById('prog-wrap').scrollIntoView()")
QTimer.singleShot(400, save)
QTimer.singleShot(300, lambda: wait_for_end(scrolled_save))
w.view.loadFinished.connect(lambda ok: QTimer.singleShot(500, next_shot))
QTimer.singleShot(90_000, app.quit) # never hang
t = time.monotonic()
app.exec()
print(f"selfcheck done in {time.monotonic() - t:.0f}s")
if __name__ == "__main__":
if "--selfcheck" in sys.argv:
selfcheck()
sys.exit(0)
app = QApplication(sys.argv)
app.setApplicationName(TITLE)
w = RunTabWindow()
w.show()
sys.exit(app.exec())
+152
View File
@@ -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})