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:
co-authored by
Claude Opus 5
parent
b748f95372
commit
36c5b69550
+212
-44
@@ -32,7 +32,7 @@ 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"
|
||||
VERSION = "0.9.0"
|
||||
|
||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||
_busy = threading.Event()
|
||||
@@ -55,11 +55,155 @@ def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
|
||||
stop.wait(HEARTBEAT_SECONDS)
|
||||
|
||||
|
||||
def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | None = None) -> None:
|
||||
"""Dispatch a single step. Raises StepError / DashboardError on failure."""
|
||||
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 <body> confirms it is really there.
|
||||
target = step.get("url", "")
|
||||
landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
|
||||
opts.timeout, step.get("openUrl", "") or "",
|
||||
navigate_url=target)
|
||||
# Navigating to a protected page is where an expired session shows up:
|
||||
# the site answers with a redirect to its login form.
|
||||
actions.check_signed_in(landed, step.get("signedOut", "") or "")
|
||||
report(f"tab is on {landed.get('url', target)}")
|
||||
|
||||
elif step["action"] == "waitFor":
|
||||
# A gate, not an action: block until the element exists. Nothing is
|
||||
# clicked or typed. Whatever has to make it appear — a person solving a
|
||||
# challenge, a slow server, a background job — happens outside this run.
|
||||
selector = step["selector"]
|
||||
timeout_s = float(step.get("timeoutSeconds", 120))
|
||||
deadline = time.time() + timeout_s
|
||||
announced = False
|
||||
|
||||
while True:
|
||||
# Long gates must still honour the dashboard's Stop button.
|
||||
if run_id is not None and run_status(dash, run_id) == "cancelled":
|
||||
raise actions.StepError("cancelled while waiting")
|
||||
|
||||
try:
|
||||
dash.locate(selector, int(step.get("index", 0)),
|
||||
step.get("urlPattern", "") or opts.url,
|
||||
opts.timeout, step.get("openUrl", "") or "")
|
||||
report(f"{selector} appeared")
|
||||
break
|
||||
except DashboardError:
|
||||
pass # not there yet, or the tab is mid-render
|
||||
|
||||
if time.time() >= deadline:
|
||||
raise actions.StepError(
|
||||
f"{selector} did not appear within {timeout_s:.0f}s"
|
||||
)
|
||||
if not announced:
|
||||
report(f"waiting for {selector} (up to {timeout_s:.0f}s)")
|
||||
announced = True
|
||||
time.sleep(2.0)
|
||||
|
||||
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 has not 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 "",
|
||||
signed_out=step.get("signedOut", "") 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,
|
||||
)
|
||||
|
||||
|
||||
def run_status(dash: Dashboard, run_id: int) -> str | None:
|
||||
"""Current status, so a wait can notice the dashboard's Stop button."""
|
||||
try:
|
||||
return dash._request(f"/api/autobuyer/runs?id={run_id}")["run"]["status"]
|
||||
except (DashboardError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_sign_in(dash: Dashboard, step: dict, run_id: int, opts, deadline: float) -> bool:
|
||||
"""Block until the tab leaves the login page.
|
||||
|
||||
The runner does not log in — entering credentials is the human's job, and a
|
||||
keystroke aimed at the wrong field is not something to risk automating. All
|
||||
this does is watch the tab and pick the run back up once you are through.
|
||||
"""
|
||||
signed_out = step.get("signedOut", "") or ""
|
||||
pattern = step.get("urlPattern", "") or opts.url
|
||||
open_url = step.get("openUrl", "") or ""
|
||||
|
||||
while time.time() < deadline:
|
||||
if run_status(dash, run_id) == "cancelled":
|
||||
return False
|
||||
time.sleep(3.0)
|
||||
try:
|
||||
landed = dash.locate("body", 0, pattern, opts.timeout, open_url)
|
||||
except DashboardError:
|
||||
continue # tab busy mid-redirect; look again shortly
|
||||
if signed_out not in (landed.get("url") or ""):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def signed_in_now(dash: Dashboard, step: dict, opts, settle_seconds: float = 20.0) -> bool:
|
||||
"""Did we actually get back in?
|
||||
|
||||
Auth steps can 'succeed' — every click landing — while the site still rejects
|
||||
the sign-in, so this confirms rather than assumes before the failed step is
|
||||
retried.
|
||||
|
||||
It polls rather than checking once: submitting a login form starts a network
|
||||
round trip and then a redirect, so the tab is still sitting on the login URL
|
||||
for a second or two afterwards. Checking immediately reports a failure for a
|
||||
sign-in that is merely still in flight.
|
||||
"""
|
||||
signed_out = step.get("signedOut", "") or ""
|
||||
if not signed_out:
|
||||
return True
|
||||
|
||||
deadline = time.time() + settle_seconds
|
||||
while True:
|
||||
try:
|
||||
landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
|
||||
opts.timeout, step.get("openUrl", "") or "")
|
||||
if signed_out not in (landed.get("url") or ""):
|
||||
return True
|
||||
except DashboardError:
|
||||
pass # mid-redirect the tab can be briefly un-injectable
|
||||
|
||||
if time.time() >= deadline:
|
||||
return False
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
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"]
|
||||
auth_steps = run.get("authSteps") or []
|
||||
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)")
|
||||
@@ -74,49 +218,73 @@ def run_steps(dash: Dashboard, run: dict, opts) -> 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 <body> 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
|
||||
# One retry after a sign-in: the session can only be expired once per step.
|
||||
for attempt in range(2):
|
||||
try:
|
||||
run_one_step(dash, step, opts, rng, report, run_id)
|
||||
break
|
||||
|
||||
except actions.SignedOutError as exc:
|
||||
wait_seconds = float(step.get("signedOutWait", 0) or 0)
|
||||
|
||||
# Scripted auth first, if the firm defines any; otherwise fall
|
||||
# back to pausing for a human. Only ever attempted once per step —
|
||||
# auth that "succeeds" without signing in would otherwise loop.
|
||||
if auth_steps and attempt == 0:
|
||||
post_progress(dash, run_id, i, f"signed out — running {len(auth_steps)} auth step(s)",
|
||||
False, exc.args[0])
|
||||
try:
|
||||
for astep in auth_steps:
|
||||
alabel = astep.get("label") or f"auth: {astep['action']}"
|
||||
print(f" [auth] {alabel}")
|
||||
run_one_step(dash, astep, opts, rng, report, run_id)
|
||||
except (actions.StepError, DashboardError) as aexc:
|
||||
msg = f"auth steps failed: {aexc}"
|
||||
post_progress(dash, run_id, i, label, False, msg)
|
||||
finish(dash, run_id, msg)
|
||||
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not signed_in_now(dash, step, opts):
|
||||
msg = "auth steps ran but the session is still signed out"
|
||||
post_progress(dash, run_id, i, label, False, msg)
|
||||
finish(dash, run_id, msg)
|
||||
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
|
||||
return
|
||||
|
||||
report("signed in via auth steps — retrying")
|
||||
continue
|
||||
|
||||
if wait_seconds <= 0 or attempt > 0:
|
||||
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
|
||||
|
||||
mins = wait_seconds / 60
|
||||
note = f"signed out — waiting up to {mins:.0f} min for you to log in"
|
||||
print(f" {note}", file=sys.stderr)
|
||||
post_progress(dash, run_id, i, note, False, exc.args[0])
|
||||
|
||||
if not wait_for_sign_in(dash, step, run_id, opts, time.time() + wait_seconds):
|
||||
if run_status(dash, run_id) == "cancelled":
|
||||
print(f"■ run #{run_id} cancelled while waiting for sign-in")
|
||||
return
|
||||
msg = f"still signed out after {mins:.0f} min"
|
||||
post_progress(dash, run_id, i, label, False, msg)
|
||||
finish(dash, run_id, msg)
|
||||
print(f"✗ run #{run_id} gave up waiting for sign-in")
|
||||
return
|
||||
|
||||
report("signed in — retrying the step")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user