Add automation framework, typing, and runner for the autobuyer
Turns the autobuyer from a page scraper into something that acts. A dashboard button queues a run; a desktop process executes it against the real browser. lib/automations.ts — automations are declarative step lists nested inside the firm whose site they drive. Steps are click / type / wait / navigate, and they inherit the firm's tab pattern and URL, so one firm's automation can't act on another's tab. Adding a button means adding an entry here; the page renders buttons from the API and the runner receives steps from the server, so neither needs editing. Runs key on firm:automation — every firm will plausibly have its own "buy-accounts", and a bare id would resolve to the wrong one. clicker/runner.py — the daemon behind the buttons. Claims a queued run, works through the steps, reports each one back for the page's live log. Only one run executes at a time: two processes driving one physical mouse would interleave clicks. Heartbeats on its own thread, because a step can block for tens of seconds and folding the beat into the main loop would show the runner as offline in the middle of the run it was executing. clicker/actions.py — one implementation of the safety checks, shared by the CLI and the runner. Refuses to act when the element is covered by an overlay, when coordinates fall off-screen, when the browser can't be confirmed frontmost, or (for type) when the target isn't an editable field. Typing: uneven human cadence, and the field is read back afterwards and compared against what was typed — a field that never took focus fails silently and looks identical to success otherwise. Non-ASCII is rejected because pyautogui skips those characters without complaint, and newlines because Enter may submit the form. Typos are deliberately not simulated: a mistyped digit in a trading form is a real loss, and the correction is the part that can go wrong. Extension: opens the firm's page when no tab matches, navigates to a specific page for a navigate step (skipped when already there, so page state survives), and retries the locate while a freshly loaded React app mounts — `complete` only means the document loaded. Staleness reporting, after it cost three debugging rounds: Chrome doesn't reload an unpacked extension and Python doesn't reload a running process, so both now report their version. A stale runner gets a red banner naming both versions and the automation buttons are disabled, rather than failing mid-run on a step type it predates. Scale detection is now conservative: a raw OS/browser width ratio is only trusted when it lands on a real scaling factor. On this multi-monitor desktop the previous logic would have silently halved every coordinate. Verified end to end against the live browser: navigate, locate, and a real click (run #12, all three steps). API round-trips, claim-once semantics, run cancellation, the heartbeat online/offline lifecycle, motion geometry and timing, focus activation, and typing verification all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
54221bbc0c
commit
b748f95372
@@ -392,6 +392,8 @@ export interface LocateRow {
|
||||
selector: string;
|
||||
match_index: number;
|
||||
url_pattern: string;
|
||||
open_url: string;
|
||||
navigate_url: string;
|
||||
status: 'pending' | 'claimed' | 'done' | 'error';
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
@@ -401,10 +403,10 @@ export interface LocateRow {
|
||||
|
||||
const LOCATE_HISTORY = 20;
|
||||
|
||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string): LocateRow {
|
||||
export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = ''): LocateRow {
|
||||
const res = db.prepare(
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, created_at) VALUES (?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, Date.now());
|
||||
'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now());
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_locate
|
||||
@@ -436,3 +438,150 @@ export function resolveLocateRequest(id: number, result: unknown | null, error:
|
||||
db.prepare('UPDATE autobuyer_locate SET status = ?, result = ?, error = ?, resolved_at = ? WHERE id = ?')
|
||||
.run(error ? 'error' : 'done', result ? JSON.stringify(result) : null, error, Date.now(), id);
|
||||
}
|
||||
|
||||
// ── AutoBuyer automation runs ────────────────────────────────────────────────
|
||||
//
|
||||
// The dashboard queues a run; the Python runner claims it and works through the
|
||||
// steps, reporting progress back so the page can show what is happening.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS autobuyer_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
step_index INTEGER NOT NULL DEFAULT 0,
|
||||
total_steps INTEGER NOT NULL DEFAULT 0,
|
||||
log TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: the page to send the tab to before locating.
|
||||
try {
|
||||
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: the page to open when no tab matches the pattern.
|
||||
try {
|
||||
db.exec("ALTER TABLE autobuyer_locate ADD COLUMN open_url TEXT NOT NULL DEFAULT ''");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
export type RunStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled';
|
||||
|
||||
export interface RunRow {
|
||||
id: number;
|
||||
automation_id: string;
|
||||
status: RunStatus;
|
||||
step_index: number;
|
||||
total_steps: number;
|
||||
log: string; // JSON { at: number; step: string; ok: boolean; detail?: string }[]
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const RUN_HISTORY = 30;
|
||||
|
||||
export function createRun(automationId: string, totalSteps: 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);
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM autobuyer_runs
|
||||
WHERE id NOT IN (SELECT id FROM autobuyer_runs ORDER BY id DESC LIMIT ?)
|
||||
`).run(RUN_HISTORY);
|
||||
|
||||
return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(res.lastInsertRowid) as RunRow;
|
||||
}
|
||||
|
||||
export function getRun(id: number): RunRow | undefined {
|
||||
return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(id) as RunRow | undefined;
|
||||
}
|
||||
|
||||
export function getRecentRuns(limit = 5): RunRow[] {
|
||||
return db.prepare('SELECT * FROM autobuyer_runs ORDER BY id DESC LIMIT ?').all(limit) as RunRow[];
|
||||
}
|
||||
|
||||
export function countActiveRuns(): number {
|
||||
const row = db.prepare("SELECT COUNT(*) AS n FROM autobuyer_runs WHERE status IN ('queued','running')").get() as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const running = db.prepare("SELECT 1 FROM autobuyer_runs WHERE status = 'running'").get();
|
||||
if (running) return undefined;
|
||||
|
||||
const row = db.prepare("SELECT * FROM autobuyer_runs WHERE status = 'queued' ORDER BY id LIMIT 1").get() as RunRow | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
db.prepare("UPDATE autobuyer_runs SET status = 'running', updated_at = ? WHERE id = ?").run(Date.now(), row.id);
|
||||
return { ...row, status: 'running' };
|
||||
}
|
||||
|
||||
export function appendRunLog(id: number, entry: unknown, stepIndex: number): void {
|
||||
const row = getRun(id);
|
||||
if (!row) return;
|
||||
let log: unknown[];
|
||||
try { log = JSON.parse(row.log); } catch { log = []; }
|
||||
log.push(entry);
|
||||
db.prepare('UPDATE autobuyer_runs SET log = ?, step_index = ?, updated_at = ? WHERE id = ?')
|
||||
.run(JSON.stringify(log), stepIndex, Date.now(), id);
|
||||
}
|
||||
|
||||
export function finishRun(id: number, status: RunStatus, error: string | null): void {
|
||||
db.prepare('UPDATE autobuyer_runs SET status = ?, error = ?, updated_at = ? WHERE id = ?')
|
||||
.run(status, error, Date.now(), id);
|
||||
}
|
||||
|
||||
/** Cancelling a queued run stops it starting; cancelling a running one is seen
|
||||
* by the runner between steps. */
|
||||
export function cancelRun(id: number): boolean {
|
||||
const row = getRun(id);
|
||||
if (!row || row.status === 'done' || row.status === 'error' || row.status === 'cancelled') return false;
|
||||
finishRun(id, 'cancelled', null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Runner heartbeat ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The runner is a desktop process, so the server can't tell whether it's alive.
|
||||
// It reports in every couple of seconds; if the beats stop, the dashboard greys
|
||||
// out the buttons rather than queueing runs nobody will execute.
|
||||
|
||||
/** Three missed beats. Long enough to ride out a slow tick, short enough that a
|
||||
* killed runner shows as offline before you press anything. */
|
||||
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.4.0';
|
||||
|
||||
export interface RunnerHeartbeat {
|
||||
at: number;
|
||||
host?: string;
|
||||
pid?: number;
|
||||
dryRun?: boolean;
|
||||
busy?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function setRunnerHeartbeat(info: Omit<RunnerHeartbeat, 'at'>): void {
|
||||
setSetting('runner_heartbeat', JSON.stringify({ ...info, at: Date.now() }));
|
||||
}
|
||||
|
||||
export function getRunnerHeartbeat(): RunnerHeartbeat | null {
|
||||
const raw = getSetting('runner_heartbeat');
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw) as RunnerHeartbeat; } catch { return null; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user