diff --git a/app/api/autobuyer/automations/route.ts b/app/api/autobuyer/automations/route.ts
index 118c673..9f49a0e 100644
--- a/app/api/autobuyer/automations/route.ts
+++ b/app/api/autobuyer/automations/route.ts
@@ -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;
+ const inputs: Record = {};
+ 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 });
}
diff --git a/app/api/autobuyer/runs/claim/route.ts b/app/api/autobuyer/runs/claim/route.ts
index e643604..dbadd8a 100644
--- a/app/api/autobuyer/runs/claim/route.ts
+++ b/app/api/autobuyer/runs/claim/route.ts
@@ -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 {
+ try {
+ const parsed = JSON.parse(json);
+ return parsed && typeof parsed === 'object' ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
export async function OPTIONS() {
return corsPreflight();
}
diff --git a/app/api/autobuyer/runs/route.ts b/app/api/autobuyer/runs/route.ts
index 1cbe01c..809f647 100644
--- a/app/api/autobuyer/runs/route.ts
+++ b/app/api/autobuyer/runs/route.ts
@@ -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>) {
/** `?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));
diff --git a/app/autobuyer/page.tsx b/app/autobuyer/page.tsx
index 43fcbeb..8f70bad 100644
--- a/app/autobuyer/page.tsx
+++ b/app/autobuyer/page.tsx
@@ -34,6 +34,14 @@ interface Capture {
capturedAt: number;
}
+interface AutomationInput {
+ id: string;
+ label: string;
+ default: number;
+ min: number;
+ max: number;
+}
+
interface AutomationSummary {
/** firm:automation — what runs are recorded against. */
key: string;
@@ -41,6 +49,7 @@ interface AutomationSummary {
label: string;
description: string;
confirm: string | null;
+ inputs: AutomationInput[];
steps: string[];
}
@@ -120,6 +129,8 @@ export default function AutoBuyer() {
const [runs, setRuns] = useState([]);
const [busyRun, setBusyRun] = useState(false);
const [runner, setRunner] = useState(null);
+ // Per-automation input values, keyed ".".
+ const [inputValues, setInputValues] = useState>({});
// Tracks the newest capture we already hold, so the poll can skip re-downloading it.
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 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) {
- // Anything that drives the real mouse against a broker gets a confirmation.
+ const inputs: Record = {};
+ 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(
- `${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;
setBusyRun(true);
@@ -193,7 +218,7 @@ export default function AutoBuyer() {
const res = await fetch('/api/autobuyer/automations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ automationId: automation.key }),
+ body: JSON.stringify({ automationId: automation.key, inputs }),
});
const data = await res.json();
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.inputs.map((input) => (
+
+ ))}
+
))}
diff --git a/clicker/clicker.py b/clicker/clicker.py
index ecf2d08..f0daf30 100644
--- a/clicker/clicker.py
+++ b/clicker/clicker.py
@@ -38,6 +38,25 @@ class DashboardError(RuntimeError):
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:
"""Thin client for the autobuyer endpoints."""
@@ -86,7 +105,9 @@ class Dashboard:
if row["status"] == "done":
return row["result"]
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)
raise DashboardError(
diff --git a/clicker/runner.py b/clicker/runner.py
index c7ff2bd..a356e80 100644
--- a/clicker/runner.py
+++ b/clicker/runner.py
@@ -24,7 +24,7 @@ import threading
import time
import actions
-from clicker import Dashboard, DashboardError
+from clicker import Dashboard, DashboardError, NotFoundError
POLL_SECONDS = 1.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
# 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.
-VERSION = "0.9.0"
+VERSION = "0.11.0"
# Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event()
@@ -115,23 +115,30 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
)
else:
- actions.perform(
- dash,
- step["action"],
- step["selector"],
- index=int(step.get("index", 0)),
- url=step.get("urlPattern", "") or opts.url,
- open_url=step.get("openUrl", "") or "",
- signed_out=step.get("signedOut", "") or "",
- text=step.get("text"),
- clear=bool(step.get("clear")),
- scale=opts.scale,
- timeout=opts.timeout,
- rng=rng,
- activate=not opts.no_activate,
- dry_run=opts.dry_run,
- report=report,
- )
+ try:
+ actions.perform(
+ dash,
+ step["action"],
+ step["selector"],
+ index=int(step.get("index", 0)),
+ url=step.get("urlPattern", "") or opts.url,
+ open_url=step.get("openUrl", "") or "",
+ signed_out=step.get("signedOut", "") or "",
+ text=step.get("text"),
+ clear=bool(step.get("clear")),
+ scale=opts.scale,
+ timeout=opts.timeout,
+ rng=rng,
+ activate=not opts.no_activate,
+ dry_run=opts.dry_run,
+ 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:
diff --git a/extension/manifest.json b/extension/manifest.json
index e1cc351..1f240df 100644
--- a/extension/manifest.json
+++ b/extension/manifest.json
@@ -1,11 +1,12 @@
{
"manifest_version": 3,
"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.",
"permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [
"https://*.tradeify.co/*",
+ "https://*.lucidtrading.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
diff --git a/lib/automations.ts b/lib/automations.ts
index c9cf205..8324ca0 100644
--- a/lib/automations.ts
+++ b/lib/automations.ts
@@ -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;
+
+/** 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,
+ 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'})`;
+ }
}
}
diff --git a/lib/db.ts b/lib/db.ts
index f864bfb..f840594 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -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
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 = {}): 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;