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 { 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 { createRun, getSetting } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors'; import { corsJson, corsPreflight } from '../cors';
@@ -16,6 +16,7 @@ export async function GET() {
label: a.label, label: a.label,
description: a.description, description: a.description,
confirm: a.confirm ?? null, confirm: a.confirm ?? null,
inputs: a.inputs ?? [],
steps: a.steps.map(describeStep), steps: a.steps.map(describeStep),
})), })),
})), })),
@@ -25,7 +26,7 @@ export async function GET() {
/** Queue a run. The Python runner picks it up. */ /** Queue a run. The Python runner picks it up. */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json() as { automationId?: unknown }; const body = await req.json() as { automationId?: unknown; inputs?: unknown };
if (typeof body.automationId !== 'string') { if (typeof body.automationId !== 'string') {
return corsJson({ error: '`automationId` is required' }, { status: 400 }); 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 }); return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 });
} }
const run = createRun(body.automationId, found.automation.steps.length); // Clamp every input to what the automation declared. The count decides how
return corsJson({ runId: run.id, totalSteps: run.total_steps }); // 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) { } catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 }); 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, automationId: row.automation_id,
firm: found.firm.label, firm: found.firm.label,
label: found.automation.label, label: found.automation.label,
steps: resolveSteps(found.firm, found.automation), steps: resolveSteps(found.firm, found.automation, safeInputs(row.inputs)),
authSteps: resolveAuthSteps(found.firm), 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() { export async function OPTIONS() {
return corsPreflight(); return corsPreflight();
} }
+3 -1
View File
@@ -1,5 +1,5 @@
import { NextRequest } from 'next/server'; 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 { findAutomation } from '@/lib/automations';
import { corsJson, corsPreflight } from '../cors'; 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. */ /** `?id=` for one run, otherwise the recent history the dashboard shows. */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
reapOrphanedRuns();
const idParam = req.nextUrl.searchParams.get('id'); const idParam = req.nextUrl.searchParams.get('id');
if (idParam) { if (idParam) {
const row = getRun(Number(idParam)); const row = getRun(Number(idParam));
+46 -3
View File
@@ -34,6 +34,14 @@ interface Capture {
capturedAt: number; capturedAt: number;
} }
interface AutomationInput {
id: string;
label: string;
default: number;
min: number;
max: number;
}
interface AutomationSummary { interface AutomationSummary {
/** firm:automation — what runs are recorded against. */ /** firm:automation — what runs are recorded against. */
key: string; key: string;
@@ -41,6 +49,7 @@ interface AutomationSummary {
label: string; label: string;
description: string; description: string;
confirm: string | null; confirm: string | null;
inputs: AutomationInput[];
steps: string[]; steps: string[];
} }
@@ -120,6 +129,8 @@ export default function AutoBuyer() {
const [runs, setRuns] = useState<Run[]>([]); const [runs, setRuns] = useState<Run[]>([]);
const [busyRun, setBusyRun] = useState(false); const [busyRun, setBusyRun] = useState(false);
const [runner, setRunner] = useState<RunnerStatus | null>(null); 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. // Tracks the newest capture we already hold, so the poll can skip re-downloading it.
const lastAtRef = useRef(0); 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 activeRun = runs.find((r) => r.status === 'queued' || r.status === 'running') ?? null;
const firmAutomations = firms.find((f) => f.id === activeFirm)?.automations ?? []; 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) { 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( 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; )) return;
setBusyRun(true); setBusyRun(true);
@@ -193,7 +218,7 @@ export default function AutoBuyer() {
const res = await fetch('/api/autobuyer/automations', { const res = await fetch('/api/autobuyer/automations', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ automationId: automation.key }), body: JSON.stringify({ automationId: automation.key, inputs }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) setError(data.error ?? 'Failed to start'); 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'} {a.description} · {a.steps.length} step{a.steps.length === 1 ? '' : 's'}
</p> </p>
</div> </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 <button
onClick={() => startRun(a)} onClick={() => startRun(a)}
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale} disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
@@ -382,6 +424,7 @@ export default function AutoBuyer() {
{a.label} {a.label}
</button> </button>
</div> </div>
</div>
))} ))}
</div> </div>
+22 -1
View File
@@ -38,6 +38,25 @@ class DashboardError(RuntimeError):
pass pass
class NotFoundError(DashboardError):
"""The extension reached the page and the element simply isn't there.
Kept distinct from every other DashboardError — a dead extension, an
unreachable dashboard, a missing tab — so that a `skipIfNotFound` step can skip a
genuinely absent element without also swallowing a broken pipeline. Those
look identical from a distance and must not be treated alike.
"""
# What the extension says when the page loaded fine but the selector matched
# nothing. See pageLocate in extension/background.js.
_ABSENT_MARKERS = ("No element matches", "no index")
def _is_absent(message: str) -> bool:
return any(marker in message for marker in _ABSENT_MARKERS)
class Dashboard: class Dashboard:
"""Thin client for the autobuyer endpoints.""" """Thin client for the autobuyer endpoints."""
@@ -86,7 +105,9 @@ class Dashboard:
if row["status"] == "done": if row["status"] == "done":
return row["result"] return row["result"]
if row["status"] == "error": if row["status"] == "error":
raise DashboardError(f"Extension could not locate it: {row['error']}") detail = row["error"] or ""
cls = NotFoundError if _is_absent(detail) else DashboardError
raise cls(f"Extension could not locate it: {detail}")
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
raise DashboardError( raise DashboardError(
+9 -2
View File
@@ -24,7 +24,7 @@ import threading
import time import time
import actions import actions
from clicker import Dashboard, DashboardError from clicker import Dashboard, DashboardError, NotFoundError
POLL_SECONDS = 1.0 POLL_SECONDS = 1.0
HEARTBEAT_SECONDS = 2.0 HEARTBEAT_SECONDS = 2.0
@@ -32,7 +32,7 @@ HEARTBEAT_SECONDS = 2.0
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in # Bumped whenever the step vocabulary or the locate protocol changes. Reported in
# the heartbeat so the dashboard can say "restart your runner" instead of letting # the heartbeat so the dashboard can say "restart your runner" instead of letting
# a stale process fail on a step type it has never heard of. # a stale process fail on a step type it has never heard of.
VERSION = "0.9.0" VERSION = "0.11.0"
# Shared with the heartbeat thread: whether a run is currently executing. # Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event() _busy = threading.Event()
@@ -115,6 +115,7 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
) )
else: else:
try:
actions.perform( actions.perform(
dash, dash,
step["action"], step["action"],
@@ -132,6 +133,12 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
dry_run=opts.dry_run, dry_run=opts.dry_run,
report=report, report=report,
) )
except NotFoundError:
# Only absence is skippable. A covered element, an off-screen target
# or an unreachable extension still fails the run.
if not step.get("skipIfNotFound"):
raise
report(f"{step['selector']} not present — skipped")
def run_status(dash: Dashboard, run_id: int) -> str | None: def run_status(dash: Dashboard, run_id: int) -> str | None:
+2 -1
View File
@@ -1,11 +1,12 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "AutoFirmer Capture", "name": "AutoFirmer Capture",
"version": "0.4.0", "version": "0.5.0",
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.", "description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
"permissions": ["scripting", "tabs", "storage", "alarms"], "permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [ "host_permissions": [
"https://*.tradeify.co/*", "https://*.tradeify.co/*",
"https://*.lucidtrading.com/*",
"http://localhost/*", "http://localhost/*",
"http://127.0.0.1/*" "http://127.0.0.1/*"
], ],
+177 -21
View File
@@ -11,20 +11,46 @@
// permitted to read that site and every step fails to locate. // permitted to read that site and every step fails to locate.
export type AutomationStep = export type AutomationStep =
| { action: 'click'; selector: string; index?: number; urlPattern?: string; label?: string } // `skipIfNotFound` skips the step when the element isn't on the page, instead of
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; urlPattern?: string; label?: string } // failing the run — for things that only sometimes appear, like a cookie
// banner or a confirmation modal. Only absence is tolerated: an element that
// is present but covered or off-screen still fails.
| { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; label?: string }
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: string; label?: string }
| { action: 'wait'; seconds: number; label?: string } | { action: 'wait'; seconds: number; label?: string }
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped // Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
// when the tab is already there, so it doesn't reload and lose page state. // when the tab is already there, so it doesn't reload and lose page state.
| { action: 'navigate'; url?: string; label?: string } | { action: 'navigate'; url?: string; label?: string }
// Block until `selector` exists, then carry on. Nothing is clicked or typed — // Block until `selector` exists, then carry on. Nothing is clicked or typed —
// this is a gate, for conditions something outside the run has to satisfy. // this is a gate, for conditions something outside the run has to satisfy.
| { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string }; | { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string }
// Run `steps` several times over. `times` fixes the count here; `timesFrom`
// takes it from an input the user fills in on the dashboard. The block is
// unrolled before the runner ever sees it — see resolveSteps.
| { action: 'repeat'; times?: number; timesFrom?: string; steps: AutomationStep[]; label?: string };
/** A number the user supplies on the dashboard before starting a run. */
export interface AutomationInput {
id: string;
label: string;
default: number;
min: number;
max: number;
}
export type RunInputs = Record<string, number>;
/** Unrolling a repeat multiplies steps, and each one can be a purchase. Cap the
* expansion so a bad input can't queue a thousand clicks. */
const MAX_RESOLVED_STEPS = 400;
const MAX_REPEAT_DEPTH = 3;
export interface Automation { export interface Automation {
id: string; id: string;
label: string; label: string;
description: string; description: string;
/** Values the user sets per run. Referenced by `timesFrom` on a repeat. */
inputs?: AutomationInput[];
/** Shown as a confirmation before the run. Set it on anything that spends money. */ /** Shown as a confirmation before the run. Set it on anything that spends money. */
confirm?: string; confirm?: string;
steps: AutomationStep[]; steps: AutomationStep[];
@@ -81,14 +107,15 @@ export const FIRMS: Firm[] = [
signedOutPattern: '/auth/', signedOutPattern: '/auth/',
signedOutWaitSeconds: 300, signedOutWaitSeconds: 300,
authSteps: [ authSteps: [
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Open Add Account' }, { action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
], ],
automations: [ automations: [
{ {
id: 'buy-accounts', id: 'buy-accounts',
label: 'Buy Accounts', label: 'Buy Accounts x 5',
description: 'Opens the Add Account flow.', description: 'Opens the Add Account flow.',
confirm: 'This drives the real mouse against Tradeify and can spend money. Continue?', confirm: 'This drives the real mouse against Tradeify and can spend money. Continue?',
inputs: [{ id: 'count', label: 'Accounts - 5 Pack', default: 1, min: 1, max: 3 }],
steps: [ steps: [
// The tab is routinely left on another Tradeify page (/the-circuit, // The tab is routinely left on another Tradeify page (/the-circuit,
// an account view). The Add Account link only exists on the // an account view). The Add Account link only exists on the
@@ -98,6 +125,7 @@ export const FIRMS: Firm[] = [
// link — confirmed against a real capture, matchCount 1. The MUI // link — confirmed against a real capture, matchCount 1. The MUI
// hash classes on the same element (mui-*) are regenerated on // hash classes on the same element (mui-*) are regenerated on
// every site build, so they are not safe to select on. // every site build, so they are not safe to select on.
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' }, { action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
{ action: 'wait', seconds: 2, label: 'Wait for the form' }, { action: 'wait', seconds: 2, label: 'Wait for the form' },
{ action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'}, { action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'},
@@ -114,6 +142,65 @@ export const FIRMS: Firm[] = [
], ],
}, },
{
id: 'reset',
label: 'Reset Accounts',
description: 'Resets Accounts',
confirm: 'This resets accounts',
inputs: [{ id: 'count', label: 'Accounts', default: 1, min: 1, max: 10 }],
steps: [
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
{ action: 'click', selector: 'div.MuiTabs-scroller button:nth-child(2)', label: 'Click Evaluation Tab'},
{ action: 'click', selector: '.tab-head-right .MuiSwitch-colorPrimary.Mui-checked', skipIfNotFound: true, label: 'Show failed accounts' },
{ action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: 'button.reset_btn', label: 'Reset Account' },
{ action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' },
{ action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
{ action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' },
{ action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' }
]},
]
}
],
},
{
id: 'lucid',
label: 'Lucid Trading',
// One pattern covers both hosts: Chrome's `*.` form matches the apex as
// well as subdomains, confirmed against a live tab on lucidtrading.com.
urlPattern: 'https://*.lucidtrading.com/*',
url: 'https://dash.lucidtrading.com/',
// Signed out lands on the apex; signed in stays on dash. The leading `//`
// anchors this to the start of the host — without it, the substring also
// matches dash.lucidtrading.com and every step would abort while logged in.
signedOutPattern: '//lucidtrading.com/',
signedOutWaitSeconds: 300,
authSteps: [
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
],
automations: [
{
id: 'buy-account',
label: 'Buy Account',
description: 'Opens the Add Account flow.',
confirm: 'This drives the real mouse against Lucid and can spend money. Continue?',
inputs: [{ id: 'count', label: 'Accounts', default: 1, min: 1, max: 15 }],
steps: [
// The tab is routinely left on another Tradeify page (/the-circuit,
// an account view). The Add Account link only exists on the
// dashboard, so go there first rather than assuming.
{ action: 'navigate', label: 'Open the Lucid dashboard' },
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
{ action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
]},
],
},
], ],
}, },
]; ];
@@ -138,10 +225,32 @@ export function findAutomation(key: string): { firm: Firm; automation: Automatio
return undefined; return undefined;
} }
/** Fill in the firm's tab pattern for any step that didn't name one, so the /** Clamp a user-supplied count to what the automation declared. Never trust the
* runner never has to know which firm it is working on. */ * number that arrived over the wire: each iteration can be a purchase. */
export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[] { export function resolveCount(
return automation.steps.map((step) => { step: Extract<AutomationStep, { action: 'repeat' }>,
automation: Automation,
inputs: RunInputs,
): number {
if (!step.timesFrom) return Math.max(1, Math.floor(step.times ?? 1));
const declared = (automation.inputs ?? []).find((i) => i.id === step.timesFrom);
const raw = inputs[step.timesFrom];
if (!declared) return 1;
const n = Number.isFinite(raw) ? Math.floor(raw) : declared.default;
return Math.min(declared.max, Math.max(declared.min, n));
}
/** Give each step of an unrolled iteration a label that says which pass it is,
* so a failure on the third purchase reads as such in the run log. */
function labelled(step: ResolvedStep, iteration: number, total: number): ResolvedStep {
if (total <= 1) return step;
return { ...step, label: `${step.label ?? describeStep(step)} (${iteration}/${total})` };
}
/** One non-repeat step, with the firm's tab pattern and URLs filled in. */
function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
if (step.action === 'wait') return step; if (step.action === 'wait') return step;
if (step.action === 'navigate') { if (step.action === 'navigate') {
return { return {
@@ -156,6 +265,10 @@ export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[]
if (step.action === 'waitFor') { if (step.action === 'waitFor') {
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url }; return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
} }
if (step.action === 'repeat') {
// expand() peels these off first; reaching here means a caller bypassed it.
throw new Error('repeat steps must be expanded, not resolved directly');
}
return { return {
...step, ...step,
urlPattern: step.urlPattern ?? firm.urlPattern, urlPattern: step.urlPattern ?? firm.urlPattern,
@@ -163,23 +276,61 @@ export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[]
signedOut: firm.signedOutPattern, signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds, signedOutWait: firm.signedOutWaitSeconds,
}; };
}); }
/** Flatten repeats into a plain list.
*
* Expanding here rather than looping in the runner keeps the runner unchanged,
* makes the run's total step count honest, and puts every iteration in the run
* log as its own line. The cost is that the count must be known up front, which
* it is: either fixed in the config or supplied before the run starts.
*/
function expand(
steps: AutomationStep[],
firm: Firm,
automation: Automation,
inputs: RunInputs,
depth = 0,
): ResolvedStep[] {
if (depth > MAX_REPEAT_DEPTH) {
throw new Error(`repeat nested more than ${MAX_REPEAT_DEPTH} deep`);
}
const out: ResolvedStep[] = [];
for (const step of steps) {
if (step.action === 'repeat') {
const times = resolveCount(step, automation, inputs);
for (let i = 1; i <= times; i++) {
for (const inner of expand(step.steps, firm, automation, inputs, depth + 1)) {
out.push(labelled(inner, i, times));
}
}
} else {
out.push(resolveOne(firm, step));
}
if (out.length > MAX_RESOLVED_STEPS) {
throw new Error(`expands to more than ${MAX_RESOLVED_STEPS} steps`);
}
}
return out;
}
export function resolveSteps(firm: Firm, automation: Automation, inputs: RunInputs = {}): ResolvedStep[] {
return expand(automation.steps, firm, automation, inputs);
} }
/** Auth steps run on the login page itself, so they get the firm's tab pattern /** Auth steps run on the login page itself, so they get the firm's tab pattern
* but deliberately no `signedOut` guard that guard exists to stop ordinary * but deliberately no `signedOut` guard that guard exists to stop ordinary
* steps acting on a login form, and these are the exception. */ * steps acting on a login form, and these are the exception.
*
* They share the expansion path, so a repeat works here too; there are no
* per-run inputs during auth, so `timesFrom` falls back to a single pass. */
export function resolveAuthSteps(firm: Firm): ResolvedStep[] { export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
return firm.authSteps.map((step) => { const stub: Automation = { id: '_auth', label: 'auth', description: '', steps: firm.authSteps };
if (step.action === 'wait') return step; return expand(firm.authSteps, firm, stub, {}).map((step) =>
if (step.action === 'navigate') { step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined }
return { ...step, url: step.url ?? firm.url, urlPattern: firm.urlPattern, openUrl: firm.url }; );
}
if (step.action === 'waitFor') {
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
return { ...step, urlPattern: step.urlPattern ?? firm.urlPattern, openUrl: firm.url };
});
} }
/** What a step is doing, for the run log and the dashboard. */ /** What a step is doing, for the run log and the dashboard. */
@@ -191,5 +342,10 @@ export function describeStep(step: AutomationStep): string {
case 'wait': return `wait ${step.seconds}s`; case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`; case 'navigate': return `open ${step.url ?? 'the firm page'}`;
case 'waitFor': return `wait for ${step.selector}`; case 'waitFor': return `wait for ${step.selector}`;
case 'repeat': {
const inner = step.steps.length;
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
return `repeat ${count}× (${inner} step${inner === 1 ? '' : 's'})`;
}
} }
} }
+43 -4
View File
@@ -452,6 +452,7 @@ db.exec(`
step_index INTEGER NOT NULL DEFAULT 0, step_index INTEGER NOT NULL DEFAULT 0,
total_steps INTEGER NOT NULL DEFAULT 0, total_steps INTEGER NOT NULL DEFAULT 0,
log TEXT NOT NULL DEFAULT '[]', log TEXT NOT NULL DEFAULT '[]',
inputs TEXT NOT NULL DEFAULT '{}',
error TEXT, error TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
@@ -472,6 +473,13 @@ try {
// Column already exists // Column already exists
} }
// Migration: per-run inputs the user set on the dashboard.
try {
db.exec("ALTER TABLE autobuyer_runs ADD COLUMN inputs TEXT NOT NULL DEFAULT '{}'");
} catch {
// Column already exists
}
export type RunStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled'; export type RunStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled';
export interface RunRow { export interface RunRow {
@@ -481,6 +489,7 @@ export interface RunRow {
step_index: number; step_index: number;
total_steps: number; total_steps: number;
log: string; // JSON { at: number; step: string; ok: boolean; detail?: string }[] log: string; // JSON { at: number; step: string; ok: boolean; detail?: string }[]
inputs: string; // JSON Record<string, number>
error: string | null; error: string | null;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
@@ -488,11 +497,11 @@ export interface RunRow {
const RUN_HISTORY = 30; const RUN_HISTORY = 30;
export function createRun(automationId: string, totalSteps: number): RunRow { export function createRun(automationId: string, totalSteps: number, inputs: Record<string, number> = {}): RunRow {
const now = Date.now(); const now = Date.now();
const res = db.prepare( const res = db.prepare(
'INSERT INTO autobuyer_runs (automation_id, total_steps, created_at, updated_at) VALUES (?, ?, ?, ?)' 'INSERT INTO autobuyer_runs (automation_id, total_steps, inputs, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
).run(automationId, totalSteps, now, now); ).run(automationId, totalSteps, JSON.stringify(inputs), now, now);
db.prepare(` db.prepare(`
DELETE FROM autobuyer_runs DELETE FROM autobuyer_runs
@@ -515,9 +524,39 @@ export function countActiveRuns(): number {
return row.n; return row.n;
} }
/** Grace period before a 'running' run with nobody behind it is written off.
* Long enough to cover the gap between the runner claiming a run and its next
* heartbeat reporting busy. */
const ORPHAN_GRACE_MS = 30_000;
/** Fail runs that were left mid-flight when their runner went away.
*
* Only one run executes at a time, so a stale 'running' row blocks every future
* run kill the daemon during a run (or restart it, which is the same thing)
* and nothing would ever be claimed again. The heartbeat says whether anything
* is actually executing: a runner that is gone, or up and idle, is not driving
* this run no matter what its status column claims.
*/
export function reapOrphanedRuns(): number {
const hb = getRunnerHeartbeat();
const alive = hb !== null && Date.now() - hb.at < RUNNER_TIMEOUT_MS;
if (alive && hb!.busy) return 0;
const now = Date.now();
return db.prepare(`
UPDATE autobuyer_runs
SET status = 'error',
error = 'the runner stopped while this run was in progress',
updated_at = ?
WHERE status = 'running' AND updated_at < ?
`).run(now, now - ORPHAN_GRACE_MS).changes;
}
/** The runner takes the oldest queued run. Only one runs at a time two /** The runner takes the oldest queued run. Only one runs at a time two
* processes driving the same physical mouse would interleave clicks. */ * processes driving the same physical mouse would interleave clicks. */
export function claimRun(): RunRow | undefined { export function claimRun(): RunRow | undefined {
reapOrphanedRuns();
const running = db.prepare("SELECT 1 FROM autobuyer_runs WHERE status = 'running'").get(); const running = db.prepare("SELECT 1 FROM autobuyer_runs WHERE status = 'running'").get();
if (running) return undefined; if (running) return undefined;
@@ -565,7 +604,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
/** The runner version this server's step vocabulary requires. A running process /** The runner version this server's step vocabulary requires. A running process
* doesn't reload when the source changes, so an older one silently fails on * doesn't reload when the source changes, so an older one silently fails on
* steps it predates the dashboard warns instead. */ * steps it predates the dashboard warns instead. */
export const RUNNER_EXPECTED_VERSION = '0.9.0'; export const RUNNER_EXPECTED_VERSION = '0.11.0';
export interface RunnerHeartbeat { export interface RunnerHeartbeat {
at: number; at: number;