Add automation framework, typing, and runner for the autobuyer

Turns the autobuyer from a page scraper into something that acts. A dashboard
button queues a run; a desktop process executes it against the real browser.

lib/automations.ts — automations are declarative step lists nested inside the
firm whose site they drive. Steps are click / type / wait / navigate, and they
inherit the firm's tab pattern and URL, so one firm's automation can't act on
another's tab. Adding a button means adding an entry here; the page renders
buttons from the API and the runner receives steps from the server, so neither
needs editing. Runs key on firm:automation — every firm will plausibly have its
own "buy-accounts", and a bare id would resolve to the wrong one.

clicker/runner.py — the daemon behind the buttons. Claims a queued run, works
through the steps, reports each one back for the page's live log. Only one run
executes at a time: two processes driving one physical mouse would interleave
clicks. Heartbeats on its own thread, because a step can block for tens of
seconds and folding the beat into the main loop would show the runner as offline
in the middle of the run it was executing.

clicker/actions.py — one implementation of the safety checks, shared by the CLI
and the runner. Refuses to act when the element is covered by an overlay, when
coordinates fall off-screen, when the browser can't be confirmed frontmost, or
(for type) when the target isn't an editable field.

Typing: uneven human cadence, and the field is read back afterwards and compared
against what was typed — a field that never took focus fails silently and looks
identical to success otherwise. Non-ASCII is rejected because pyautogui skips
those characters without complaint, and newlines because Enter may submit the
form. Typos are deliberately not simulated: a mistyped digit in a trading form
is a real loss, and the correction is the part that can go wrong.

Extension: opens the firm's page when no tab matches, navigates to a specific
page for a navigate step (skipped when already there, so page state survives),
and retries the locate while a freshly loaded React app mounts — `complete` only
means the document loaded.

Staleness reporting, after it cost three debugging rounds: Chrome doesn't reload
an unpacked extension and Python doesn't reload a running process, so both now
report their version. A stale runner gets a red banner naming both versions and
the automation buttons are disabled, rather than failing mid-run on a step type
it predates.

Scale detection is now conservative: a raw OS/browser width ratio is only
trusted when it lands on a real scaling factor. On this multi-monitor desktop
the previous logic would have silently halved every coordinate.

Verified end to end against the live browser: navigate, locate, and a real
click (run #12, all three steps). API round-trips, claim-once semantics, run
cancellation, the heartbeat online/offline lifecycle, motion geometry and
timing, focus activation, and typing verification all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-28 14:00:33 -05:00
co-authored by Claude Opus 5
parent 54221bbc0c
commit b748f95372
17 changed files with 1463 additions and 99 deletions
+121
View File
@@ -0,0 +1,121 @@
// Automations live inside the firm whose site they drive. The dashboard renders
// a tab per firm and a button per automation within it; the Python runner claims
// a queued run, resolves each selector through the extension, and drives the real
// mouse.
//
// Adding a button means adding an entry to that firm's `automations` — nothing in
// the page, the API or the runner changes.
//
// Adding a NEW FIRM also needs its host added to extension/manifest.json
// host_permissions, and the extension reloaded. Without that the extension is not
// permitted to read that site and every step fails to locate.
export type AutomationStep =
| { action: 'click'; selector: string; index?: number; urlPattern?: string; label?: string }
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; urlPattern?: string; label?: string }
| { 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 };
export interface Automation {
id: string;
label: string;
description: string;
/** Shown as a confirmation before the run. Set it on anything that spends money. */
confirm?: string;
steps: AutomationStep[];
}
/** A step as handed to the runner: the firm's tab pattern and fallback URL
* filled in, so the runner never has to know which firm it is working on. */
export type ResolvedStep = AutomationStep & {
urlPattern?: string;
openUrl?: string;
};
export interface Firm {
id: string;
label: string;
/** Chrome match pattern for this firm's tab. Steps inherit it unless they set
* their own, which keeps one firm's automation from acting on another's tab. */
urlPattern: string;
/** 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;
automations: Automation[];
}
export const FIRMS: Firm[] = [
{
id: 'tradeify',
label: 'Tradeify',
urlPattern: 'https://app-f.tradeify.co/*',
url: 'https://app-f.tradeify.co/',
automations: [
{
id: 'buy-accounts',
label: 'Buy Accounts',
description: 'Opens the Add Account flow.',
confirm: 'This drives the real mouse against Tradeify and can spend money. Continue?',
steps: [
// The tab is routinely left on another Tradeify page (/the-circuit,
// an account view). The Add Account link only exists on the
// dashboard, so go there first rather than assuming.
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
// `a.add_account_btn` is the authored class on the Add Account
// link — confirmed against a real capture, matchCount 1. The MUI
// hash classes on the same element (mui-*) are regenerated on
// 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' },
// TODO: the rest of the purchase flow. Confirm each selector with
// `clicker.py locate` before adding it here.
],
},
],
},
];
/** Runs store one string, so it has to identify the automation globally — and
* every firm will plausibly have its own "buy-accounts". Hence firm:automation
* rather than the bare id. */
export function automationKey(firmId: string, automationId: string): string {
return `${firmId}:${automationId}`;
}
export function getFirm(id: string): Firm | undefined {
return FIRMS.find((f) => f.id === id);
}
export function findAutomation(key: string): { firm: Firm; automation: Automation } | undefined {
for (const firm of FIRMS) {
for (const automation of firm.automations) {
if (automationKey(firm.id, automation.id) === key) return { firm, automation };
}
}
return undefined;
}
/** Fill in the firm's tab pattern for any step that didn't name one, so the
* 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 };
}
return { ...step, urlPattern: step.urlPattern ?? firm.urlPattern, openUrl: firm.url };
});
}
/** What a step is doing, for the run log and the dashboard. */
export function describeStep(step: AutomationStep): string {
if (step.label) return step.label;
switch (step.action) {
case 'click': return `click ${step.selector}`;
case 'type': return `type into ${step.selector}`;
case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
}
}