#!/usr/bin/env python3 """Automation runner — the process that makes dashboard buttons do something. Leave this running. It polls the dashboard for queued runs, and when one appears it works through that automation's steps, driving the real mouse and keyboard, reporting each step back so the page can show progress. python runner.py # against http://localhost:3000 python runner.py --api http://vps:3000 Steps come from the server, so adding a new button means editing lib/automations.ts — nothing here needs to change. Stop with Ctrl-C. A run in progress can be halted from the dashboard's Stop button; the runner notices between steps. """ import argparse import os import random import socket import sys import threading import time import actions from clicker import Dashboard, DashboardError POLL_SECONDS = 1.0 HEARTBEAT_SECONDS = 2.0 # Bumped whenever the step vocabulary or the locate protocol changes. Reported in # the heartbeat so the dashboard can say "restart your runner" instead of letting # a stale process fail on a step type it has never heard of. VERSION = "0.4.0" # Shared with the heartbeat thread: whether a run is currently executing. _busy = threading.Event() def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None: """Check in on our own thread. Deliberately not folded into the main poll loop: a single step can block for twenty seconds waiting on the extension, and a runner that goes quiet that long would show as offline in the middle of the run it is executing. """ payload = {"host": socket.gethostname(), "pid": os.getpid(), "dryRun": opts.dry_run, "version": VERSION} while not stop.is_set(): try: dash._request("/api/autobuyer/runner", "POST", {**payload, "busy": _busy.is_set()}) except DashboardError: pass # the main loop reports connectivity; don't double up stop.wait(HEARTBEAT_SECONDS) def run_steps(dash: Dashboard, run: dict, opts) -> None: """Work through one automation. Reports every step; stops on the first failure, because a half-completed purchase flow should not barrel on.""" run_id = run["id"] steps = run["steps"] rng = random.Random(opts.seed) if opts.seed is not None else random.Random() print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)") for i, step in enumerate(steps): label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip() print(f" [{i + 1}/{len(steps)}] {label}") detail_parts: list[str] = [] def report(message: str) -> None: detail_parts.append(message) print(f" {message}") try: if step["action"] == "wait": time.sleep(float(step.get("seconds", 1))) report(f"waited {step.get('seconds', 1)}s") elif step["action"] == "navigate": # No mouse involved: the extension points the tab at the page and # waits for it to load. Locating confirms it's really there. target = step.get("url", "") dash.locate("body", 0, step.get("urlPattern", "") or opts.url, opts.timeout, step.get("openUrl", "") or "", navigate_url=target) report(f"tab is on {target}") elif step["action"] not in ("click", "type"): # Almost always a stale runner: the server defines the step # vocabulary, so a step type this process has never heard of means # automations.ts has moved on and this file hasn't been restarted. raise actions.StepError( f"unknown step action {step['action']!r} — this runner is " f"v{VERSION}; restart it to pick up newer step types" ) else: actions.perform( dash, step["action"], step["selector"], index=int(step.get("index", 0)), url=step.get("urlPattern", "") or opts.url, open_url=step.get("openUrl", "") or "", text=step.get("text"), clear=bool(step.get("clear")), scale=opts.scale, timeout=opts.timeout, rng=rng, activate=not opts.no_activate, dry_run=opts.dry_run, report=report, ) except (actions.StepError, DashboardError) as exc: print(f" FAILED: {exc}", file=sys.stderr) post_progress(dash, run_id, i, label, False, str(exc)) finish(dash, run_id, str(exc)) print(f"✗ run #{run_id} stopped at step {i + 1}") return status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None) # The dashboard's Stop button shows up here. if status == "cancelled": print(f"■ run #{run_id} cancelled from the dashboard") return finish(dash, run_id, None) print(f"✓ run #{run_id} complete") def post_progress(dash, run_id, step_index, step, ok, detail): try: res = dash._request("/api/autobuyer/runs/progress", "POST", { "id": run_id, "stepIndex": step_index, "step": step, "ok": ok, "detail": detail, }) return res.get("status") except DashboardError as exc: print(f" (could not report progress: {exc})", file=sys.stderr) return None def finish(dash, run_id, error): try: dash._request("/api/autobuyer/runs/progress", "POST", {"id": run_id, "finish": True, "error": error}) except DashboardError as exc: print(f" (could not report completion: {exc})", file=sys.stderr) def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL") parser.add_argument("--url", default="", help="fallback Chrome match pattern for steps that omit one") parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension per step (a cold page load takes time)") parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio") parser.add_argument("--seed", type=int, default=None, help="seed the motion RNG (debugging)") parser.add_argument("--dry-run", action="store_true", help="move the cursor through the steps but never press or type") parser.add_argument("--no-activate", action="store_true", help="do not raise the browser before each step") opts = parser.parse_args() try: import pyautogui # noqa: F401 except ImportError: print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr) return 1 dash = Dashboard(opts.api) print(f"Runner watching {opts.api}" + (" [DRY RUN — nothing will be pressed]" if opts.dry_run else "")) print("Waiting for a run. Press a button on the AutoBuyer page. Ctrl-C to stop.") stop = threading.Event() beat = threading.Thread(target=heartbeat_loop, args=(dash, opts, stop), daemon=True) beat.start() idle_warned = False while True: try: claim = dash._request("/api/autobuyer/runs/claim", "POST", {}) except DashboardError as exc: if not idle_warned: print(f" ({exc})", file=sys.stderr) idle_warned = True time.sleep(POLL_SECONDS * 3) continue idle_warned = False run = claim.get("run") if not run: time.sleep(POLL_SECONDS) continue _busy.set() try: run_steps(dash, run, opts) except Exception as exc: # keep the daemon alive print(f"✗ run failed unexpectedly: {exc}", file=sys.stderr) finish(dash, run["id"], f"runner error: {exc}") finally: _busy.clear() if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print("\nstopped.") sys.exit(0)