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
+2 -1
View File
@@ -1,5 +1,5 @@
import { claimRun, getSetting } from '@/lib/db';
import { findAutomation, resolveSteps } from '@/lib/automations';
import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations';
import { corsJson, corsPreflight } from '../../cors';
/** The Python runner polls this. Returns the run plus the steps to execute, so
@@ -24,6 +24,7 @@ export async function POST() {
firm: found.firm.label,
label: found.automation.label,
steps: resolveSteps(found.firm, found.automation),
authSteps: resolveAuthSteps(found.firm),
},
});
}
+1 -10
View File
@@ -301,16 +301,7 @@ export default function AutoBuyer() {
? 'Runner offline'
: runner.busy ? 'Runner busy' : 'Runner online'}
</span>
{runner?.online && runner.stale && (
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-800">
The runner is out of date (reporting v{runner.version ?? 'unknown'}, this
dashboard needs v{runner.expectedVersion}). It will fail on step types it
predates. Restart it: stop with Ctrl-C and run{' '}
<code className="font-mono">python clicker/runner.py</code> again.
</div>
)}
{runner?.online && runner.dryRun && (
{runner?.online && runner.dryRun && (
<span className="ml-1 px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
dry run
</span>
+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")
+2
View File
@@ -119,6 +119,7 @@ def main() -> int:
parser.add_argument("--index", type=int, default=0, help="which match, if the selector hits several (default 0)")
parser.add_argument("--url", default="", help='Chrome match pattern for the tab, e.g. "https://tradeify.co/*"')
parser.add_argument("--open-url", default="", help="page to open if no tab matches --url")
parser.add_argument("--signed-out", default="", help="URL substring identifying the login page; abort if the tab lands there")
parser.add_argument("--api", default=DEFAULT_API, help=f"dashboard URL (default {DEFAULT_API})")
parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension (default 45; a cold page load takes time)")
parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio (default: auto-detect)")
@@ -210,6 +211,7 @@ def main() -> int:
index=args.index,
url=args.url,
open_url=args.open_url,
signed_out=args.signed_out,
text=text,
clear=args.clear,
scale=args.scale,
+212 -44
View File
@@ -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)
+75 -1
View File
@@ -16,7 +16,10 @@ export type AutomationStep =
| { action: 'wait'; seconds: number; label?: string }
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
// when the tab is already there, so it doesn't reload and lose page state.
| { action: 'navigate'; url?: string; label?: string };
| { action: 'navigate'; url?: string; label?: string }
// Block until `selector` exists, then carry on. Nothing is clicked or typed —
// this is a gate, for conditions something outside the run has to satisfy.
| { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string };
export interface Automation {
id: string;
@@ -32,6 +35,8 @@ export interface Automation {
export type ResolvedStep = AutomationStep & {
urlPattern?: string;
openUrl?: string;
signedOut?: string;
signedOutWait?: number;
};
export interface Firm {
@@ -43,6 +48,27 @@ export interface Firm {
/** Concrete page to open when no tab matches `urlPattern`. A match pattern
* can't be navigated to, so this has to be spelled out separately. */
url: string;
/** Substring identifying the signed-out page. When a session expires the site
* redirects here, and every subsequent selector resolves against a login form
* so a run that lands on it must stop rather than click through it. */
signedOutPattern: string;
/** Seconds to pause and let a human sign in when a run hits the login page.
* 0 aborts instead. Used only when `authSteps` is empty. */
signedOutWaitSeconds: number;
/** Steps run when a run lands on the login page, before retrying the step
* that hit it. Left empty here on purpose fill it in yourself.
*
* Two things to know if you do:
* - These run *while on the signed-out page*, so unlike normal steps they
* carry no signed-out guard. Nothing stops them clicking around a login
* form; that is the point, and also why a wrong selector here is worse
* than elsewhere.
* - Anything written here lives in this file in plain text, and this file
* is in the repo.
*
* While empty, a run that hits the login page falls back to pausing for
* `signedOutWaitSeconds` so you can sign in by hand. */
authSteps: AutomationStep[];
automations: Automation[];
}
@@ -52,6 +78,11 @@ export const FIRMS: Firm[] = [
label: 'Tradeify',
urlPattern: 'https://app-f.tradeify.co/*',
url: 'https://app-f.tradeify.co/',
signedOutPattern: '/auth/',
signedOutWaitSeconds: 300,
authSteps: [
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Open Add Account' },
],
automations: [
{
id: 'buy-accounts',
@@ -69,8 +100,18 @@ export const FIRMS: Firm[] = [
// every site build, so they are not safe to select on.
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
{ action: 'wait', seconds: 2, label: 'Wait for the form' },
{ action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'},
{ action: 'click', selector: 'div.account_types:nth-child(7) span:last-child'},
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
{ action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'},
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'},
{ action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
{ action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120,
label: 'Wait for the challenge' },
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' },
// TODO: the rest of the purchase flow. Confirm each selector with
// `clicker.py locate` before adding it here.
],
},
],
@@ -101,10 +142,42 @@ export function findAutomation(key: string): { firm: Firm; automation: Automatio
* runner never has to know which firm it is working on. */
export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[] {
return automation.steps.map((step) => {
if (step.action === 'wait') return step;
if (step.action === 'navigate') {
return {
...step,
url: step.url ?? firm.url,
urlPattern: firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
};
}
if (step.action === 'waitFor') {
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
return {
...step,
urlPattern: step.urlPattern ?? firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
};
});
}
/** Auth steps run on the login page itself, so they get the firm's tab pattern
* but deliberately no `signedOut` guard that guard exists to stop ordinary
* steps acting on a login form, and these are the exception. */
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
return firm.authSteps.map((step) => {
if (step.action === 'wait') return step;
if (step.action === 'navigate') {
return { ...step, url: step.url ?? firm.url, urlPattern: firm.urlPattern, openUrl: firm.url };
}
if (step.action === 'waitFor') {
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
return { ...step, urlPattern: step.urlPattern ?? firm.urlPattern, openUrl: firm.url };
});
}
@@ -117,5 +190,6 @@ export function describeStep(step: AutomationStep): string {
case 'type': return `type into ${step.selector}`;
case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
case 'waitFor': return `wait for ${step.selector}`;
}
}
+1 -1
View File
@@ -565,7 +565,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
/** The runner version this server's step vocabulary requires. A running process
* doesn't reload when the source changes, so an older one silently fails on
* steps it predates the dashboard warns instead. */
export const RUNNER_EXPECTED_VERSION = '0.4.0';
export const RUNNER_EXPECTED_VERSION = '0.9.0';
export interface RunnerHeartbeat {
at: number;