Files
Brandon LiandClaude Opus 5 22db6eae8d Add repeat blocks, per-run inputs, skipIfNotFound, and orphaned-run recovery
Repeat. A `repeat` block runs its steps several times, with the count either
fixed in the config or taken from an input the user sets on the dashboard. The
block is unrolled in resolveSteps before the runner sees it, so the runner needs
no loop, the run's total step count stays honest, and every iteration appears in
the log as its own line — a failure on the third purchase reads as "(3/5)"
rather than as an indistinguishable repeat of the first.

Counts are clamped server-side against the automation's declared min/max, and
expansion is capped at 400 steps and three levels of nesting. Each iteration can
be a purchase, so the number is not taken on trust from the client, and the
confirmation dialog names it before anything runs.

skipIfNotFound on a click or type step tolerates an element that is not on the
page — a cookie banner, a modal that only sometimes appears. Only absence is
tolerated. That distinction needed a new NotFoundError: previously a missing
element, an unreachable dashboard, a missing tab and a covered button all
surfaced as the same DashboardError, and skipping that whole class would mean a
step quietly passing while the extension was down.

Orphaned runs are now reaped. Only one run executes at a time, so a run left in
'running' when its runner went away blocked every future run — restarting the
daemon mid-run deadlocked the queue, which is exactly what happened. The
heartbeat decides: a runner that is gone, or up and reporting idle, is not
driving that run whatever the status column says. Gated on the busy flag rather
than elapsed time alone, since a run sitting in a waitFor gate or a sign-in wait
can legitimately go minutes without progress.

Lucid Trading is scaffolded with no automations yet. One match pattern covers
both its hosts — `*.` matches the apex as well as subdomains, confirmed against
a live tab. Its signed-out pattern is `//lucidtrading.com/` rather than
`lucidtrading.com/dashboard`: the leading slashes anchor it to the start of the
host, and without them the substring also matches dash.lucidtrading.com, which
would abort every step while properly signed in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:22:45 -05:00

608 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
interface ElementPosition {
index: number;
tag: string;
id: string | null;
text: string;
visible: boolean;
inViewport: boolean;
viewport: { x: number; y: number; width: number; height: number };
page: { x: number; y: number };
screen: { x: number; y: number };
}
interface ViewportMetrics {
scrollX: number;
scrollY: number;
innerWidth: number;
innerHeight: number;
screenX: number;
screenY: number;
chromeHeight: number;
devicePixelRatio: number;
}
interface Capture {
url: string;
title: string;
html: string;
viewport: Partial<ViewportMetrics>;
elements: ElementPosition[];
capturedAt: number;
}
interface AutomationInput {
id: string;
label: string;
default: number;
min: number;
max: number;
}
interface AutomationSummary {
/** firm:automation — what runs are recorded against. */
key: string;
id: string;
label: string;
description: string;
confirm: string | null;
inputs: AutomationInput[];
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
* from, otherwise the render comes out unstyled. The HTML parser hoists a leading
* <base> into <head>, so prepending works even if the markup has no explicit head. */
function withBaseTag(html: string, url: string): string {
const base = `<base href="${url.replace(/"/g, '&quot;')}">`;
const head = html.match(/<head\b[^>]*>/i);
if (!head || head.index === undefined) return base + html;
const at = head.index + head[0].length;
return html.slice(0, at) + base + html.slice(at);
}
export default function AutoBuyer() {
const [enabled, setEnabled] = useState(false);
const [capture, setCapture] = useState<Capture | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<'rendered' | 'source'>('source');
// The extension posts a new capture every few seconds, and re-feeding srcDoc would
// 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);
// Per-automation input values, keyed "<automationKey>.<inputId>".
const [inputValues, setInputValues] = useState<Record<string, number>>({});
// Tracks the newest capture we already hold, so the poll can skip re-downloading it.
const lastAtRef = useRef(0);
const poll = useCallback(async () => {
try {
const s = await fetch('/api/autobuyer/status', { cache: 'no-store' }).then((r) => r.json());
setEnabled(!!s.enabled);
if (!s.enabled) return;
const url = lastAtRef.current
? `/api/autobuyer/capture?since=${lastAtRef.current}`
: '/api/autobuyer/capture';
const data = await fetch(url, { cache: 'no-store' }).then((r) => r.json());
if (data.unchanged || !data.capture) return;
lastAtRef.current = data.capture.capturedAt;
setCapture(data.capture);
setError(null);
} catch (err: any) {
setError(err?.message ?? 'Poll failed');
}
}, []);
useEffect(() => {
poll();
const id = setInterval(poll, POLL_MS);
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 ?? [];
function inputValue(automation: AutomationSummary, input: AutomationInput): number {
return inputValues[`${automation.key}.${input.id}`] ?? input.default;
}
async function startRun(automation: AutomationSummary) {
const inputs: Record<string, number> = {};
for (const input of automation.inputs) inputs[input.id] = inputValue(automation, input);
// Anything that drives the real mouse against a broker gets a confirmation,
// and it names the counts — the difference between buying one and buying ten
// is a number in a box that is easy to misread.
const summary = automation.inputs
.map((i) => `${i.label}: ${inputs[i.id]}`)
.join('\n');
if (automation.confirm && !window.confirm(
`${automation.confirm}` +
(summary ? `\n\n${summary}` : '') +
`\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, inputs }),
});
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 {
const res = await fetch('/api/autobuyer/status', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !enabled }),
});
const data = await res.json();
setEnabled(!!data.enabled);
setError(null);
} catch (err: any) {
setError(err?.message ?? 'Failed to toggle');
} finally {
setBusy(false);
}
}
function showRendered() {
setView('rendered');
if (capture) setPinned(capture);
}
async function clearCapture() {
await fetch('/api/autobuyer/capture', { method: 'DELETE' });
lastAtRef.current = 0;
setCapture(null);
setPinned(null);
}
const stale = capture ? Date.now() - capture.capturedAt > 15000 : false;
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-slate-900 mb-6">AutoBuyer</h1>
{/* ── Capture switch and daemon status ── */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-6">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Page capture</p>
<p className="text-xs text-slate-400 mt-0.5">
While on, the AutoFirmer Capture extension posts the target tab&apos;s HTML here every few seconds
</p>
</div>
<button
onClick={toggle}
disabled={busy}
className={`text-sm px-4 py-1.5 rounded-lg font-medium transition-colors disabled:opacity-50 ${
enabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-slate-200 hover:bg-slate-300 text-slate-700'
}`}
>
{busy ? '…' : enabled ? 'ON' : 'OFF'}
</button>
</div>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Autoclicker daemon</p>
<p className="text-xs text-slate-400 mt-0.5">
{runner?.online
? <>Executes queued runs · {runner.host ?? 'unknown host'} pid {runner.pid ?? '?'} · v{runner.version ?? '?'}</>
: <>Not running start it with <code className="font-mono text-slate-500">python clicker/runner.py</code></>}
</p>
</div>
<span className="flex items-center gap-2 text-xs">
{runner?.online && runner.dryRun && (
<span className="px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
dry run
</span>
)}
{runner?.online && runner.stale && (
<span className="px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-medium">
out of date
</span>
)}
<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-600 font-medium' : 'text-slate-400'}>
{!runner?.online
? 'Offline'
: runner.busy ? 'Busy' : 'Online'}
</span>
</span>
</div>
</div>
{error && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{error}
</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>
))}
</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>
<div className="flex items-center gap-3">
{a.inputs.map((input) => (
<label key={input.id} className="flex items-center gap-2 text-xs text-slate-500">
{input.label}
<input
type="number"
min={input.min}
max={input.max}
value={inputValue(a, input)}
onChange={(e) => setInputValues((prev) => ({
...prev,
[`${a.key}.${input.id}`]: Number(e.target.value),
}))}
className="w-16 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 text-sm text-right font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</label>
))}
<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>
))}
</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
</h2>
{!enabled ? (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
Capture is off
</div>
) : !capture ? (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
Waiting for the extension open a page in Chrome with the extension installed
</div>
) : (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 px-4 py-3 border-b border-slate-100">
<span className="text-sm font-medium text-slate-800">{capture.title || '(untitled)'}</span>
<span className="text-xs font-mono text-slate-400 break-all">{capture.url}</span>
<span className="ml-auto flex items-center gap-3 text-xs text-slate-400">
<span>{(capture.html.length / 1024).toFixed(1)} KB</span>
<span className={stale ? 'text-amber-600' : ''}>
{new Date(capture.capturedAt).toLocaleTimeString()}
{stale && ' (stale)'}
</span>
<span className="flex rounded-md border border-slate-200 overflow-hidden">
{(['rendered', 'source'] as const).map((v) => (
<button
key={v}
onClick={() => (v === 'rendered' ? showRendered() : setView('source'))}
className={`px-2 py-0.5 capitalize transition-colors ${
view === v
? 'bg-slate-700 text-white'
: 'text-slate-500 hover:bg-slate-50'
}`}
>
{v}
</button>
))}
</span>
<button
onClick={clearCapture}
className="text-slate-400 hover:text-red-500 transition-colors"
>
Clear
</button>
</span>
</div>
{capture.viewport?.innerWidth != null && (
<div className="px-4 py-2 border-b border-slate-100 flex flex-wrap gap-x-5 gap-y-1 text-xs font-mono text-slate-500">
<span>viewport {capture.viewport.innerWidth}×{capture.viewport.innerHeight}</span>
<span>scroll {Math.round(capture.viewport.scrollX ?? 0)},{Math.round(capture.viewport.scrollY ?? 0)}</span>
<span>window@screen {capture.viewport.screenX},{capture.viewport.screenY}</span>
<span>chrome {capture.viewport.chromeHeight}px</span>
<span>dpr {capture.viewport.devicePixelRatio}</span>
</div>
)}
{capture.elements?.length > 0 && (
<div className="px-4 py-3 border-b border-slate-100">
<p className="text-xs uppercase tracking-wider text-slate-400 mb-2">
Matched elements ({capture.elements.length})
</p>
<div className="overflow-x-auto">
<table className="text-xs font-mono text-slate-600">
<thead className="text-slate-400">
<tr>
<th className="text-left pr-4 font-normal">tag</th>
<th className="text-left pr-4 font-normal">text</th>
<th className="text-right pr-4 font-normal">window x,y</th>
<th className="text-right pr-4 font-normal">size</th>
<th className="text-right pr-4 font-normal">page x,y</th>
<th className="text-right pr-4 font-normal">screen x,y</th>
<th className="text-left font-normal">vis</th>
</tr>
</thead>
<tbody>
{capture.elements.map((el) => (
<tr key={el.index} className="border-t border-slate-50">
<td className="pr-4 py-1">{el.tag}{el.id ? `#${el.id}` : ''}</td>
<td className="pr-4 py-1 max-w-xs truncate">{el.text}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.viewport.x)},{Math.round(el.viewport.y)}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.viewport.width)}×{Math.round(el.viewport.height)}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.page.x)},{Math.round(el.page.y)}</td>
<td className="pr-4 py-1 text-right">{Math.round(el.screen.x)},{Math.round(el.screen.y)}</td>
<td className="py-1">{el.inViewport ? '✓' : el.visible ? 'off-screen' : 'hidden'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{view === 'rendered' ? (
<>
{pinned && pinned.capturedAt !== capture.capturedAt && (
<div className="px-4 py-1.5 border-b border-slate-100 text-xs text-amber-600">
Frozen snapshot from {new Date(pinned.capturedAt).toLocaleTimeString()} click Rendered again to refresh
</div>
)}
{/* allow-scripts WITHOUT allow-same-origin: the frame runs the
page's own JS inside an opaque origin, so it cannot reach this
dashboard's DOM, storage, or same-origin API routes — /api/firms
serves firm credentials. Granting both flags together is what
would let a frame drop its own sandbox.
Scripts are needed because most sites ship content at opacity:0
and fade it in with JS — blocked, they render blank. */}
<iframe
sandbox="allow-scripts"
srcDoc={withBaseTag((pinned ?? capture).html, (pinned ?? capture).url)}
title="Captured page"
className="block w-full h-[70vh] bg-white"
/>
</>
) : (
<pre className="max-h-[70vh] overflow-auto bg-slate-900 text-slate-200 text-xs font-mono p-4 whitespace-pre-wrap break-all">
{capture.html}
</pre>
)}
</div>
)}
</div>
</div>
);
}