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
+192 -36
View File
@@ -11,20 +11,46 @@
// permitted to read that site and every step fails to locate.
export type AutomationStep =
| { action: 'click'; selector: string; index?: number; urlPattern?: string; label?: string }
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; urlPattern?: string; label?: string }
// `skipIfNotFound` skips the step when the element isn't on the page, instead of
// 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 }
// 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.
| { action: 'navigate'; url?: string; label?: string }
// 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.
| { 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 {
id: string;
label: 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. */
confirm?: string;
steps: AutomationStep[];
@@ -81,14 +107,15 @@ export const FIRMS: Firm[] = [
signedOutPattern: '/auth/',
signedOutWaitSeconds: 300,
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: [
{
id: 'buy-accounts',
label: 'Buy Accounts',
label: 'Buy Accounts x 5',
description: 'Opens the Add Account flow.',
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: [
// The tab is routinely left on another Tradeify page (/the-circuit,
// 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
// hash classes on the same element (mui-*) are regenerated on
// every site build, so they are not safe to select on.
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
{ 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)'},
@@ -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,48 +225,112 @@ export function findAutomation(key: string): { firm: Firm; automation: Automatio
return undefined;
}
/** Fill in the firm's tab pattern for any step that didn't name one, so the
* runner never has to know which firm it is working on. */
export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[] {
return automation.steps.map((step) => {
if (step.action === 'wait') return step;
if (step.action === 'navigate') {
return {
...step,
url: step.url ?? firm.url,
urlPattern: firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
};
}
if (step.action === 'waitFor') {
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
/** Clamp a user-supplied count to what the automation declared. Never trust the
* number that arrived over the wire: each iteration can be a purchase. */
export function resolveCount(
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 === 'navigate') {
return {
...step,
urlPattern: step.urlPattern ?? firm.urlPattern,
url: step.url ?? firm.url,
urlPattern: firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
};
});
}
if (step.action === 'waitFor') {
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 {
...step,
urlPattern: step.urlPattern ?? firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
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
* 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[] {
return firm.authSteps.map((step) => {
if (step.action === 'wait') return step;
if (step.action === 'navigate') {
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 };
});
const stub: Automation = { id: '_auth', label: 'auth', description: '', steps: firm.authSteps };
return expand(firm.authSteps, firm, stub, {}).map((step) =>
step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined }
);
}
/** 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 'navigate': return `open ${step.url ?? 'the firm page'}`;
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'})`;
}
}
}