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
+276
View File
@@ -34,6 +34,64 @@ interface Capture {
capturedAt: number;
}
interface AutomationSummary {
/** firm:automation — what runs are recorded against. */
key: string;
id: string;
label: string;
description: string;
confirm: string | null;
steps: string[];
}
interface FirmSummary {
id: string;
label: string;
urlPattern: string;
automations: AutomationSummary[];
}
interface RunLogEntry {
at: number;
step: string;
ok: boolean;
detail?: string;
}
interface Run {
id: number;
automationId: string;
automationLabel: string;
status: 'queued' | 'running' | 'done' | 'error' | 'cancelled';
stepIndex: number;
totalSteps: number;
log: RunLogEntry[];
error: string | null;
createdAt: number;
updatedAt: number;
}
interface RunnerStatus {
online: boolean;
lastSeen: number | null;
ageMs: number | null;
host?: string | null;
pid?: number | null;
dryRun?: boolean;
busy?: boolean;
version?: string | null;
expectedVersion?: string;
stale?: boolean;
}
const RUN_BADGE: Record<Run['status'], string> = {
queued: 'bg-slate-100 text-slate-600',
running: 'bg-blue-100 text-blue-700',
done: 'bg-green-100 text-green-700',
error: 'bg-red-100 text-red-700',
cancelled: 'bg-amber-100 text-amber-700',
};
const POLL_MS = 2000;
/** Point relative URLs (stylesheets, images, fonts) at the origin the snapshot came
@@ -57,6 +115,11 @@ export default function AutoBuyer() {
// reload the frame that often. So the rendered view shows a snapshot pinned when you
// opened it — clicking Rendered again re-pins to the latest capture.
const [pinned, setPinned] = useState<Capture | null>(null);
const [firms, setFirms] = useState<FirmSummary[]>([]);
const [activeFirm, setActiveFirm] = useState<string | null>(null);
const [runs, setRuns] = useState<Run[]>([]);
const [busyRun, setBusyRun] = useState(false);
const [runner, setRunner] = useState<RunnerStatus | null>(null);
// Tracks the newest capture we already hold, so the poll can skip re-downloading it.
const lastAtRef = useRef(0);
@@ -87,6 +150,67 @@ export default function AutoBuyer() {
return () => clearInterval(id);
}, [poll]);
useEffect(() => {
fetch('/api/autobuyer/automations')
.then((r) => r.json())
.then((d) => {
const list: FirmSummary[] = d.firms ?? [];
setFirms(list);
setActiveFirm((current) => current ?? list[0]?.id ?? null);
})
.catch(() => {});
}, []);
// Runs tick faster than the capture poll — a run is something you watch.
const pollRuns = useCallback(async () => {
try {
const [d, r] = await Promise.all([
fetch('/api/autobuyer/runs', { cache: 'no-store' }).then((x) => x.json()),
fetch('/api/autobuyer/runner', { cache: 'no-store' }).then((x) => x.json()),
]);
setRuns(d.runs ?? []);
setRunner(r);
} catch { /* transient; the next tick retries */ }
}, []);
useEffect(() => {
pollRuns();
const id = setInterval(pollRuns, 1000);
return () => clearInterval(id);
}, [pollRuns]);
const activeRun = runs.find((r) => r.status === 'queued' || r.status === 'running') ?? null;
const firmAutomations = firms.find((f) => f.id === activeFirm)?.automations ?? [];
async function startRun(automation: AutomationSummary) {
// Anything that drives the real mouse against a broker gets a confirmation.
if (automation.confirm && !window.confirm(
`${automation.confirm}\n\nSteps:\n${automation.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
)) return;
setBusyRun(true);
try {
const res = await fetch('/api/autobuyer/automations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ automationId: automation.key }),
});
const data = await res.json();
if (!res.ok) setError(data.error ?? 'Failed to start');
else setError(null);
await pollRuns();
} catch (err: any) {
setError(err?.message ?? 'Failed to start');
} finally {
setBusyRun(false);
}
}
async function cancelRun(id: number) {
await fetch(`/api/autobuyer/runs?id=${id}`, { method: 'DELETE' });
await pollRuns();
}
async function toggle() {
setBusy(true);
try {
@@ -153,6 +277,158 @@ export default function AutoBuyer() {
</div>
)}
{/* ── One tab per firm ── */}
<div className="flex items-end gap-5 mb-3 border-b border-slate-200">
{firms.map((firm) => (
<button
key={firm.id}
onClick={() => setActiveFirm(firm.id)}
className={`text-sm font-semibold uppercase tracking-wider pb-2 -mb-px border-b-2 transition-colors ${
firm.id === activeFirm
? 'text-slate-900 border-slate-900'
: 'text-slate-400 border-transparent hover:text-slate-600'
}`}
>
{firm.label}
</button>
))}
<span className="ml-auto flex items-center gap-1.5 text-xs pb-2">
<span className={`w-2 h-2 rounded-full ${
runner?.online ? (runner.busy ? 'bg-blue-500 animate-pulse' : 'bg-green-500') : 'bg-slate-300'
}`} />
<span className={runner?.online ? 'text-slate-500' : 'text-slate-400'}>
{!runner?.online
? '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 && (
<span className="ml-1 px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
dry run
</span>
)}
</span>
</div>
{!runner?.online && (
<div className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600">
Nothing will run until the runner is started it&apos;s the process that
moves the mouse. In the project directory:{' '}
<code className="font-mono text-slate-800">python clicker/runner.py</code>
</div>
)}
{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 && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
The runner is in dry-run mode: it will walk through the steps and move the
cursor, but never press or type. Restart it without <code className="font-mono">--dry-run</code> to act for real.
</div>
)}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
{firmAutomations.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-slate-400">
{firms.length === 0 ? 'No firms configured' : 'No automations for this firm yet'}
</div>
) : firmAutomations.map((a, i) => (
<div
key={a.key}
className={`flex items-center justify-between px-4 py-3 ${
i < firmAutomations.length - 1 ? 'border-b border-slate-100' : ''
}`}
>
<div>
<p className="text-sm font-medium text-slate-800">{a.label}</p>
<p className="text-xs text-slate-400 mt-0.5">
{a.description} · {a.steps.length} step{a.steps.length === 1 ? '' : 's'}
</p>
</div>
<button
onClick={() => startRun(a)}
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
title={
!enabled ? 'AutoBuyer is switched off'
: !runner?.online ? 'The runner is not running'
: runner?.stale ? 'The runner is out of date — restart it'
: activeRun !== null ? 'A run is already in progress'
: undefined
}
className="text-sm px-4 py-1.5 rounded-lg font-medium bg-blue-500 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{a.label}
</button>
</div>
))}
</div>
{/* ── Run status ── */}
{runs.length > 0 && (
<>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Runs
</h2>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8 divide-y divide-slate-100">
{runs.map((run) => (
<div key={run.id} className="px-4 py-3">
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded ${RUN_BADGE[run.status]}`}>
{run.status}
</span>
<span className="text-sm text-slate-800">{run.automationLabel}</span>
<span className="text-xs font-mono text-slate-400">
step {Math.min(run.stepIndex + (run.status === 'done' ? 0 : 1), run.totalSteps)}/{run.totalSteps}
</span>
<span className="ml-auto text-xs text-slate-400">
{new Date(run.createdAt).toLocaleTimeString()}
</span>
{(run.status === 'queued' || run.status === 'running') && (
<button
onClick={() => cancelRun(run.id)}
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
>
Stop
</button>
)}
</div>
{run.log.length > 0 && (
<ol className="mt-2 space-y-0.5">
{run.log.map((entry, i) => (
<li key={i} className="text-xs font-mono text-slate-500">
<span className={entry.ok ? 'text-green-600' : 'text-red-500'}>
{entry.ok ? '✓' : '✗'}
</span>{' '}
{entry.step}
{entry.detail && <span className="text-slate-400"> {entry.detail}</span>}
</li>
))}
</ol>
)}
{run.error && (
<p className="mt-1 text-xs text-red-600">{run.error}</p>
)}
</div>
))}
</div>
</>
)}
{/* ── Latest capture ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Latest capture