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>
This commit is contained in:
Brandon Li
2026-08-30 13:22:45 -05:00
co-authored by Claude Opus 5
parent a2066fbf04
commit 22db6eae8d
9 changed files with 367 additions and 70 deletions
+46 -3
View File
@@ -34,6 +34,14 @@ interface Capture {
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;
@@ -41,6 +49,7 @@ interface AutomationSummary {
label: string;
description: string;
confirm: string | null;
inputs: AutomationInput[];
steps: string[];
}
@@ -120,6 +129,8 @@ export default function AutoBuyer() {
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);
@@ -182,10 +193,24 @@ export default function AutoBuyer() {
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) {
// Anything that drives the real mouse against a broker gets a confirmation.
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}\n\nSteps:\n${automation.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
`${automation.confirm}` +
(summary ? `\n\n${summary}` : '') +
`\n\nSteps:\n${automation.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
)) return;
setBusyRun(true);
@@ -193,7 +218,7 @@ export default function AutoBuyer() {
const res = await fetch('/api/autobuyer/automations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ automationId: automation.key }),
body: JSON.stringify({ automationId: automation.key, inputs }),
});
const data = await res.json();
if (!res.ok) setError(data.error ?? 'Failed to start');
@@ -367,6 +392,23 @@ export default function AutoBuyer() {
{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}
@@ -381,6 +423,7 @@ export default function AutoBuyer() {
>
{a.label}
</button>
</div>
</div>
))}
</div>