Add session handling, waitFor gating, and the Tradeify purchase flow

Step vocabulary gains `waitFor`: block until a selector exists, then continue.
Nothing is clicked or typed — it is a gate for conditions something outside the
run has to satisfy. Unlike every other step it carries no signed-out guard,
because the things worth gating on often sit on the login page, where that guard
would abort the run at exactly the wrong moment. Honours the dashboard's Stop
button, since a two-minute gate that ignored it would be worse than no gate.

Session handling. A run that lands on the login page must not continue: once
redirected, every selector resolves against a login form, so a click aimed at
"Add Account" hits whatever that form renders in the same place. Runs now detect
the redirect and stop before sending any input, with a distinct SignedOutError
rather than a generic failure.

Two ways out of that state, in order: a firm's `authSteps` run and the failed
step is retried, or — when none are defined — the run pauses for
signedOutWaitSeconds so a human can sign in, then resumes. Auth steps are
verified rather than trusted: they can all "succeed" while the site still
rejects the sign-in, so the session is re-checked before the retry, and the run
stops with "auth steps ran but the session is still signed out" if it did not
take.

That check polls for up to 20s instead of reading once. Submitting a login form
starts a network round trip and then a redirect, so the tab still shows the
login URL for a second or two afterwards; checking immediately failed a sign-in
that was merely in flight, killing run #15 nine seconds after it had actually
worked. Third instance of the same mistake in this system — reading page state
immediately after an action that triggers async navigation.

The runner reports its version in the heartbeat and the dashboard blocks the
buttons when it is behind. A running Python process does not reload when the
source changes, so a stale runner fails on step types it predates; that cost a
debugging round when a navigate step reached a runner that had never heard of
one.

lib/automations.ts carries the Tradeify buy-accounts flow: navigate to the
dashboard, open Add Account, pick the account type and size, enter the account
name, and work through the challenge widget before submitting. Selectors are
authored by hand against the live page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-28 16:33:25 -05:00
co-authored by Claude Opus 5
parent b748f95372
commit 36c5b69550
7 changed files with 328 additions and 58 deletions
+35 -1
View File
@@ -14,7 +14,7 @@ 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.
4 = acted but verification failed, 5 = the session is signed out.
"""
def __init__(self, message: str, code: int = 3):
@@ -22,6 +22,35 @@ class StepError(Exception):
self.code = code
class SignedOutError(StepError):
"""The tab is on the login page, so the session has expired.
Distinct from an ordinary failure because nothing is wrong with the
automation — it just cannot proceed until a human signs in.
"""
def __init__(self, url: str):
super().__init__(
f"signed out — the tab is on {url}. Log in to the site, then run this again.",
code=5,
)
def check_signed_in(found: dict, signed_out: str) -> None:
"""Abort if the page redirected to a login screen.
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.
"""
if not signed_out:
return
url = found.get("url") or ""
if signed_out in url:
raise SignedOutError(url)
# Display scaling factors that actually exist. Anything else means the two sides
# are describing different things (usually a multi-monitor desktop) rather than a
# scaled single display.
@@ -73,6 +102,7 @@ def perform(
index: int = 0,
url: str = "",
open_url: str = "",
signed_out: str = "",
text: str | None = None,
clear: bool = False,
scale: float | None = None,
@@ -101,6 +131,9 @@ def perform(
if on_located:
on_located(found)
# Before any check that assumes we are on the real page.
check_signed_in(found, signed_out)
if found.get("covered") and not force:
raise StepError(
f"{selector} is covered by {found.get('coveredBy')} — the click would hit that instead"
@@ -163,6 +196,7 @@ def perform(
# Typing into a field that never took focus, or that ignores the input, fails
# silently and is otherwise indistinguishable from success.
after = dash.locate(selector, index, url, timeout, open_url)
check_signed_in(after, signed_out)
got = after.get("value")
if got is None:
say("element exposes no value to verify against")