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
@@ -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<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) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -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<string, number> {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof getRun>>) {
|
||||
|
||||
/** `?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));
|
||||
|
||||
+46
-3
@@ -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<Run[]>([]);
|
||||
const [busyRun, setBusyRun] = useState(false);
|
||||
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.
|
||||
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<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(
|
||||
`${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'}
|
||||
</p>
|
||||
</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
|
||||
onClick={() => startRun(a)}
|
||||
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
|
||||
@@ -381,6 +423,7 @@ export default function AutoBuyer() {
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+22
-1
@@ -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(
|
||||
|
||||
+26
-19
@@ -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:
|
||||
|
||||
@@ -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/*"
|
||||
],
|
||||
|
||||
+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