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:
co-authored by
Claude Opus 5
parent
a2066fbf04
commit
22db6eae8d
+192
-36
@@ -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'})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +452,7 @@ db.exec(`
|
||||
step_index INTEGER NOT NULL DEFAULT 0,
|
||||
total_steps INTEGER NOT NULL DEFAULT 0,
|
||||
log TEXT NOT NULL DEFAULT '[]',
|
||||
inputs TEXT NOT NULL DEFAULT '{}',
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -472,6 +473,13 @@ try {
|
||||
// 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 interface RunRow {
|
||||
@@ -481,6 +489,7 @@ export interface RunRow {
|
||||
step_index: number;
|
||||
total_steps: number;
|
||||
log: string; // JSON { at: number; step: string; ok: boolean; detail?: string }[]
|
||||
inputs: string; // JSON Record<string, number>
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
@@ -488,11 +497,11 @@ export interface RunRow {
|
||||
|
||||
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 res = db.prepare(
|
||||
'INSERT INTO autobuyer_runs (automation_id, total_steps, created_at, updated_at) VALUES (?, ?, ?, ?)'
|
||||
).run(automationId, totalSteps, now, now);
|
||||
'INSERT INTO autobuyer_runs (automation_id, total_steps, inputs, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(automationId, totalSteps, JSON.stringify(inputs), now, now);
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_runs
|
||||
@@ -515,9 +524,39 @@ export function countActiveRuns(): number {
|
||||
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
|
||||
* processes driving the same physical mouse would interleave clicks. */
|
||||
export function claimRun(): RunRow | undefined {
|
||||
reapOrphanedRuns();
|
||||
|
||||
const running = db.prepare("SELECT 1 FROM autobuyer_runs WHERE status = 'running'").get();
|
||||
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
|
||||
* doesn't reload when the source changes, so an older one silently fails on
|
||||
* 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 {
|
||||
at: number;
|
||||
|
||||
Reference in New Issue
Block a user