diff --git a/app/api/autobuyer/runs/claim/route.ts b/app/api/autobuyer/runs/claim/route.ts index d6adc70..babb1d1 100644 --- a/app/api/autobuyer/runs/claim/route.ts +++ b/app/api/autobuyer/runs/claim/route.ts @@ -1,5 +1,5 @@ import { claimRun } from '@/lib/db'; -import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations'; +import { findAutomation, resolveSteps, resolveGuards, resolveAuthSteps } from '@/lib/automations'; import { corsJson, corsPreflight } from '../../cors'; /** The Python runner polls this. Returns the run plus the steps to execute, so @@ -20,6 +20,9 @@ export async function POST() { firm: found.firm.label, label: found.automation.label, steps: resolveSteps(found.firm, found.automation, safeInputs(row.inputs)), + guards: resolveGuards(found.firm), + // Pre-0.20 runners know nothing of guards; keep feeding them the + // old shape until they are restarted. authSteps: resolveAuthSteps(found.firm), }, }); diff --git a/clicker/actions.py b/clicker/actions.py index 3454796..2a139a9 100644 --- a/clicker/actions.py +++ b/clicker/actions.py @@ -15,7 +15,8 @@ class StepError(Exception): """A step refused to run, or ran and could not be verified. `code` mirrors the CLI's exit codes: 3 = refused before acting, - 4 = acted but verification failed, 5 = the session is signed out. + 4 = acted but verification failed, 5 = a page guard tripped (login + redirect, blocking modal, and the like). """ def __init__(self, message: str, code: int = 3): @@ -23,33 +24,44 @@ class StepError(Exception): self.code = code -class SignedOutError(StepError): - """The tab is on the login page, so the session has expired. +class GuardTripped(StepError): + """A page guard's condition is on the page — the run cannot proceed past it. - Distinct from an ordinary failure because nothing is wrong with the - automation — it just cannot proceed until a human signs in. + Distinct from an ordinary failure because nothing is wrong with the step: + the page has been taken over by something (a login redirect, an expired- + session modal) and every selector now resolves against the wrong document. + A click or a keystroke aimed at the old page would land on whatever the + guard page happens to render in the same place. The runner decides how to + recover; the CLI just stops with exit code 5. """ - def __init__(self, url: str): + def __init__(self, guard: dict, url: str): + name = guard.get("label") or guard.get("id") or "page" super().__init__( - f"signed out — the tab is on {url}. Log in to the site, then run this again.", + f"{name} guard tripped — the tab is on {url}. " + "Deal with the page, then run this again.", code=5, ) + self.guard = guard + self.url = url -def check_signed_in(found: dict, signed_out: str) -> None: - """Abort if the page redirected to a login screen. +def check_guards(found: dict, guards: list | None) -> None: + """Abort if the page has tripped any URL guard. - Worth doing before every action, not just at the start: a session can expire - mid-run, and once it does, every selector resolves against a login form. A - click or a keystroke aimed at the old page would land on whatever that form - happens to render in the same place. + Worth doing before every action, not just at the start: a session can + expire (or a guard page appear) mid-run. Only URL guards are checked here — + the URL rides along with every locate answer, so they are free. Selector + guards cost a locate round trip each and are probed by the runner only once + a step has already failed. """ - if not signed_out: + if not guards: return url = found.get("url") or "" - if signed_out in url: - raise SignedOutError(url) + for guard in guards: + pattern = guard.get("urlContains") or "" + if pattern and pattern in url: + raise GuardTripped(guard, url) # Display scaling factors that actually exist. Anything else means the two sides @@ -117,6 +129,7 @@ def perform( url: str = "", open_url: str = "", signed_out: str = "", + guards: list | None = None, frame: str = "", text: str | None = None, clear: bool = False, @@ -142,6 +155,12 @@ def perform( rng = rng or random.Random() say = report or (lambda _m: None) + # `guards` is the runner's list of page guards; `signed_out` is the CLI's + # single --signed-out pattern. Same check either way, so fold them together. + guards = list(guards or []) + if signed_out: + guards.append({"id": "signed-out", "label": "signed out", "urlContains": signed_out}) + # `frame` names the