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

72 lines
3.0 KiB
TypeScript

import { NextRequest } from 'next/server';
import { FIRMS, findAutomation, automationKey, describeStep, resolveSteps } from '@/lib/automations';
import { createRun, getSetting } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
/** The dashboard renders a tab per firm, and a button per automation inside it. */
export async function GET() {
return corsJson({
firms: FIRMS.map((firm) => ({
id: firm.id,
label: firm.label,
urlPattern: firm.urlPattern,
automations: firm.automations.map((a) => ({
key: automationKey(firm.id, a.id),
id: a.id,
label: a.label,
description: a.description,
confirm: a.confirm ?? null,
inputs: a.inputs ?? [],
steps: a.steps.map(describeStep),
})),
})),
});
}
/** Queue a run. The Python runner picks it up. */
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { automationId?: unknown; inputs?: unknown };
if (typeof body.automationId !== 'string') {
return corsJson({ error: '`automationId` is required' }, { status: 400 });
}
const found = findAutomation(body.automationId);
if (!found) {
return corsJson({ error: `No automation "${body.automationId}"` }, { status: 404 });
}
if (found.automation.steps.length === 0) {
return corsJson({ error: `"${found.automation.label}" has no steps yet` }, { status: 400 });
}
// The dashboard switch is the master arm for anything that drives the mouse.
if (getSetting('autobuyer_enabled') !== '1') {
return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 });
}
// Clamp every input to what the automation declared. The count decides how
// many times a purchase runs, so it is not taken on trust from the client.
const supplied = (body.inputs ?? {}) as Record<string, unknown>;
const inputs: Record<string, number> = {};
for (const declared of found.automation.inputs ?? []) {
const raw = Number(supplied[declared.id]);
const value = Number.isFinite(raw) ? Math.floor(raw) : declared.default;
inputs[declared.id] = Math.min(declared.max, Math.max(declared.min, value));
}
let resolved;
try {
resolved = resolveSteps(found.firm, found.automation, inputs);
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Could not expand the steps' }, { status: 400 });
}
const run = createRun(body.automationId, resolved.length, inputs);
return corsJson({ runId: run.id, totalSteps: run.total_steps, inputs });
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 });
}
}
export async function OPTIONS() {
return corsPreflight();
}