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
+22 -4
View File
@@ -1,5 +1,5 @@
import { NextRequest } from 'next/server';
import { FIRMS, findAutomation, automationKey, describeStep } from '@/lib/automations';
import { FIRMS, findAutomation, automationKey, describeStep, resolveSteps } from '@/lib/automations';
import { createRun, getSetting } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
@@ -16,6 +16,7 @@ export async function GET() {
label: a.label,
description: a.description,
confirm: a.confirm ?? null,
inputs: a.inputs ?? [],
steps: a.steps.map(describeStep),
})),
})),
@@ -25,7 +26,7 @@ export async function GET() {
/** Queue a run. The Python runner picks it up. */
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { automationId?: unknown };
const body = await req.json() as { automationId?: unknown; inputs?: unknown };
if (typeof body.automationId !== 'string') {
return corsJson({ error: '`automationId` is required' }, { status: 400 });
}
@@ -41,8 +42,25 @@ export async function POST(req: NextRequest) {
return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 });
}
const run = createRun(body.automationId, found.automation.steps.length);
return corsJson({ runId: run.id, totalSteps: run.total_steps });
// 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 });
}
+11 -1
View File
@@ -23,12 +23,22 @@ export async function POST() {
automationId: row.automation_id,
firm: found.firm.label,
label: found.automation.label,
steps: resolveSteps(found.firm, found.automation),
steps: resolveSteps(found.firm, found.automation, safeInputs(row.inputs)),
authSteps: resolveAuthSteps(found.firm),
},
});
}
/** Stored inputs are already clamped; this only guards against a malformed row. */
function safeInputs(json: string): Record<string, number> {
try {
const parsed = JSON.parse(json);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
export async function OPTIONS() {
return corsPreflight();
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { NextRequest } from 'next/server';
import { getRun, getRecentRuns, cancelRun } from '@/lib/db';
import { getRun, getRecentRuns, cancelRun, reapOrphanedRuns } from '@/lib/db';
import { findAutomation } from '@/lib/automations';
import { corsJson, corsPreflight } from '../cors';
@@ -20,6 +20,8 @@ function shape(row: NonNullable<ReturnType<typeof getRun>>) {
/** `?id=` for one run, otherwise the recent history the dashboard shows. */
export async function GET(req: NextRequest) {
reapOrphanedRuns();
const idParam = req.nextUrl.searchParams.get('id');
if (idParam) {
const row = getRun(Number(idParam));