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;