Step vocabulary gains `waitFor`: block until a selector exists, then continue. Nothing is clicked or typed — it is a gate for conditions something outside the run has to satisfy. Unlike every other step it carries no signed-out guard, because the things worth gating on often sit on the login page, where that guard would abort the run at exactly the wrong moment. Honours the dashboard's Stop button, since a two-minute gate that ignored it would be worse than no gate. Session handling. A run that lands on the login page must not continue: once redirected, every selector resolves against a login form, so a click aimed at "Add Account" hits whatever that form renders in the same place. Runs now detect the redirect and stop before sending any input, with a distinct SignedOutError rather than a generic failure. Two ways out of that state, in order: a firm's `authSteps` run and the failed step is retried, or — when none are defined — the run pauses for signedOutWaitSeconds so a human can sign in, then resumes. Auth steps are verified rather than trusted: they can all "succeed" while the site still rejects the sign-in, so the session is re-checked before the retry, and the run stops with "auth steps ran but the session is still signed out" if it did not take. That check polls for up to 20s instead of reading once. Submitting a login form starts a network round trip and then a redirect, so the tab still shows the login URL for a second or two afterwards; checking immediately failed a sign-in that was merely in flight, killing run #15 nine seconds after it had actually worked. Third instance of the same mistake in this system — reading page state immediately after an action that triggers async navigation. The runner reports its version in the heartbeat and the dashboard blocks the buttons when it is behind. A running Python process does not reload when the source changes, so a stale runner fails on step types it predates; that cost a debugging round when a navigate step reached a runner that had never heard of one. lib/automations.ts carries the Tradeify buy-accounts flow: navigate to the dashboard, open Add Account, pick the account type and size, enter the account name, and work through the challenge widget before submitting. Selectors are authored by hand against the live page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
588 lines
23 KiB
TypeScript
588 lines
23 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import path from 'path';
|
|
|
|
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
|
|
|
|
db.pragma('foreign_keys = ON');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS firms (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
username TEXT NOT NULL,
|
|
password TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS account_configs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
|
|
prefix TEXT NOT NULL,
|
|
profit_target REAL NOT NULL DEFAULT 3000,
|
|
consistency REAL NOT NULL DEFAULT 0.5,
|
|
min_day_pnl REAL NOT NULL DEFAULT -999,
|
|
min_trading_days INTEGER NOT NULL DEFAULT 5
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS instruments (
|
|
symbol TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
`);
|
|
|
|
// Migration: add account_size column if it doesn't exist yet
|
|
try {
|
|
db.exec('ALTER TABLE account_configs ADD COLUMN account_size REAL NOT NULL DEFAULT 50000');
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
|
|
// Migration: add max_loss column (0 = no max loss limit)
|
|
try {
|
|
db.exec('ALTER TABLE account_configs ADD COLUMN max_loss REAL NOT NULL DEFAULT 0');
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
|
|
// Migration: add max_position_size column (0 = no limit)
|
|
try {
|
|
db.exec('ALTER TABLE account_configs ADD COLUMN max_position_size INTEGER NOT NULL DEFAULT 0');
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
|
|
// Migration: add target_same_equity flag
|
|
try {
|
|
db.exec('ALTER TABLE account_configs ADD COLUMN target_same_equity INTEGER NOT NULL DEFAULT 1');
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
|
|
// Migration: add withdrawal_stages JSON array
|
|
try {
|
|
db.exec("ALTER TABLE account_configs ADD COLUMN withdrawal_stages TEXT NOT NULL DEFAULT '[]'");
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
|
|
|
|
|
|
// ── Interfaces ─────────────────────────────────────────────────────────────
|
|
|
|
export interface AccountConfigRow {
|
|
id: number;
|
|
firm_id: number;
|
|
prefix: string;
|
|
profit_target: number;
|
|
consistency: number;
|
|
min_day_pnl: number;
|
|
min_trading_days: number;
|
|
account_size: number;
|
|
max_loss: number;
|
|
max_position_size: number;
|
|
target_same_equity: number; // 0 | 1
|
|
withdrawal_stages: string; // JSON { profit: number; consistency: number; minTradingDays: number }[]
|
|
}
|
|
|
|
export interface FirmRow {
|
|
id: number;
|
|
name: string;
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface FirmWithAccounts extends FirmRow {
|
|
accounts: AccountConfigRow[];
|
|
}
|
|
|
|
// ── Firms ───────────────────────────────────────────────────────────────────
|
|
|
|
export function getFirms(): FirmWithAccounts[] {
|
|
const firms = db.prepare('SELECT * FROM firms ORDER BY id').all() as FirmRow[];
|
|
const getAccounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id');
|
|
return firms.map((firm) => ({
|
|
...firm,
|
|
accounts: getAccounts.all(firm.id) as AccountConfigRow[],
|
|
}));
|
|
}
|
|
|
|
export function getFirmById(id: number): FirmWithAccounts | undefined {
|
|
const firm = db.prepare('SELECT * FROM firms WHERE id = ?').get(id) as FirmRow | undefined;
|
|
if (!firm) return undefined;
|
|
const accounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id').all(id) as AccountConfigRow[];
|
|
return { ...firm, accounts };
|
|
}
|
|
|
|
export function createFirm(name: string, username: string, password: string): FirmRow {
|
|
const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
|
|
const result = stmt.run(name, username, password);
|
|
return db.prepare('SELECT * FROM firms WHERE id = ?').get(result.lastInsertRowid) as FirmRow;
|
|
}
|
|
|
|
export function deleteFirm(id: number): boolean {
|
|
const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
// ── Account Configs ─────────────────────────────────────────────────────────
|
|
|
|
export function createAccountConfig(firmId: number, data: {
|
|
prefix: string;
|
|
profitTarget: number;
|
|
consistency: number;
|
|
minDayPnL: number;
|
|
minTradingDays: number;
|
|
accountSize: number;
|
|
maxLoss: number;
|
|
maxPositionSize: number;
|
|
targetSameEquity?: boolean;
|
|
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
|
}): AccountConfigRow {
|
|
const stmt = db.prepare(
|
|
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size, target_same_equity, withdrawal_stages) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []));
|
|
return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow;
|
|
}
|
|
|
|
export function deleteAccountConfig(id: number): boolean {
|
|
const result = db.prepare('DELETE FROM account_configs WHERE id = ?').run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
export function updateAccountConfig(id: number, data: {
|
|
prefix: string;
|
|
profitTarget: number;
|
|
consistency: number;
|
|
minDayPnL: number;
|
|
minTradingDays: number;
|
|
accountSize: number;
|
|
maxLoss: number;
|
|
maxPositionSize: number;
|
|
targetSameEquity?: boolean;
|
|
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
|
}): boolean {
|
|
const result = db.prepare(`
|
|
UPDATE account_configs
|
|
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?, target_same_equity = ?, withdrawal_stages = ?
|
|
WHERE id = ?
|
|
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []), id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
// Seed defaults if missing
|
|
const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`);
|
|
seedSetting.run('max_concurrent_accounts', '5');
|
|
seedSetting.run('tick_interval_seconds', '60');
|
|
seedSetting.run('master_dashboard_url', '');
|
|
seedSetting.run('instance_name', '');
|
|
seedSetting.run('trading_hours', 'full_cme');
|
|
|
|
export function getSetting(key: string): string | null {
|
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
export function setSetting(key: string, value: string): void {
|
|
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
|
|
}
|
|
|
|
// ── Instruments ──────────────────────────────────────────────────────────────
|
|
|
|
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
|
|
|
|
// Seed instruments table if empty
|
|
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
|
if (instrCount === 0) {
|
|
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, 1)');
|
|
for (const s of SYMBOLS) ins.run(s);
|
|
}
|
|
|
|
export interface InstrumentRow {
|
|
symbol: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export function getInstruments(): InstrumentRow[] {
|
|
return (db.prepare('SELECT symbol, enabled FROM instruments ORDER BY symbol').all() as { symbol: string; enabled: number }[])
|
|
.map((r) => ({ symbol: r.symbol, enabled: r.enabled === 1 }));
|
|
}
|
|
|
|
export function setInstrumentEnabled(symbol: string, enabled: boolean): boolean {
|
|
const result = db.prepare('UPDATE instruments SET enabled = ? WHERE symbol = ?').run(enabled ? 1 : 0, symbol);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
// ── Daily P&L Cache ──────────────────────────────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS daily_pnl (
|
|
account_id INTEGER NOT NULL,
|
|
account_name TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
pnl REAL NOT NULL,
|
|
PRIMARY KEY (account_id, date)
|
|
);
|
|
`);
|
|
|
|
export function saveDailyPnL(accountId: number, accountName: string, entries: { date: string; pnl: number }[]): void {
|
|
const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl) VALUES (?, ?, ?, ?)');
|
|
const txn = db.transaction(() => {
|
|
for (const e of entries) {
|
|
ins.run(accountId, accountName, e.date, e.pnl);
|
|
}
|
|
});
|
|
txn();
|
|
}
|
|
|
|
export function loadDailyPnL(accountId: number): { date: string; pnl: number }[] {
|
|
return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]);
|
|
}
|
|
|
|
// ── Fund Transactions Cache ───────────────────────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS fund_transactions (
|
|
account_id INTEGER NOT NULL,
|
|
account_name TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
amount REAL NOT NULL,
|
|
PRIMARY KEY (account_id, date)
|
|
);
|
|
`);
|
|
|
|
export function saveFundTransactions(accountId: number, accountName: string, entries: { date: string; amount: number }[]): void {
|
|
const ins = db.prepare('INSERT OR REPLACE INTO fund_transactions (account_id, account_name, date, amount) VALUES (?, ?, ?, ?)');
|
|
const txn = db.transaction(() => {
|
|
for (const e of entries) ins.run(accountId, accountName, e.date, e.amount);
|
|
});
|
|
txn();
|
|
}
|
|
|
|
export function loadFundTransactions(accountId: number): { date: string; amount: number }[] {
|
|
return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[];
|
|
}
|
|
|
|
// ── Account Meta ─────────────────────────────────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS account_meta (
|
|
account_id INTEGER NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
PRIMARY KEY (account_id, key)
|
|
);
|
|
`);
|
|
|
|
export function saveAccountMeta(accountId: number, key: string, value: string): void {
|
|
db.prepare('INSERT OR REPLACE INTO account_meta (account_id, key, value) VALUES (?, ?, ?)').run(accountId, key, value);
|
|
}
|
|
|
|
export function loadAccountMeta(accountId: number, key: string): string | null {
|
|
const row = db.prepare('SELECT value FROM account_meta WHERE account_id = ? AND key = ?').get(accountId, key) as { value: string } | undefined;
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
// ── Firm banned symbols ───────────────────────────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS firm_banned_symbols (
|
|
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
|
|
symbol TEXT NOT NULL,
|
|
PRIMARY KEY (firm_id, symbol)
|
|
);
|
|
`);
|
|
|
|
export function getBannedSymbols(firmId: number): string[] {
|
|
return (db.prepare('SELECT symbol FROM firm_banned_symbols WHERE firm_id = ? ORDER BY symbol').all(firmId) as { symbol: string }[])
|
|
.map((r) => r.symbol);
|
|
}
|
|
|
|
export function isSymbolBanned(firmId: number, symbol: string): boolean {
|
|
return !!db.prepare('SELECT 1 FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').get(firmId, symbol);
|
|
}
|
|
|
|
export function setBannedSymbol(firmId: number, symbol: string, banned: boolean): void {
|
|
if (banned) {
|
|
db.prepare('INSERT OR IGNORE INTO firm_banned_symbols (firm_id, symbol) VALUES (?, ?)').run(firmId, symbol);
|
|
} else {
|
|
db.prepare('DELETE FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').run(firmId, symbol);
|
|
}
|
|
}
|
|
|
|
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS autobuyer_captures (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
url TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
html TEXT NOT NULL,
|
|
viewport TEXT NOT NULL DEFAULT '{}',
|
|
elements TEXT NOT NULL DEFAULT '[]',
|
|
captured_at INTEGER NOT NULL
|
|
);
|
|
`);
|
|
|
|
seedSetting.run('autobuyer_enabled', '0');
|
|
|
|
export interface CaptureRow {
|
|
id: number;
|
|
url: string;
|
|
title: string;
|
|
html: string;
|
|
viewport: string; // JSON ViewportMetrics
|
|
elements: string; // JSON ElementPosition[]
|
|
captured_at: number;
|
|
}
|
|
|
|
/** Number of captures kept on disk — the extension overwrites constantly, so only
|
|
* a short tail is useful and an unbounded table would grow by megabytes a minute. */
|
|
const CAPTURE_HISTORY = 5;
|
|
|
|
export function saveCapture(c: Omit<CaptureRow, 'id'>): void {
|
|
db.prepare(
|
|
'INSERT INTO autobuyer_captures (url, title, html, viewport, elements, captured_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(c.url, c.title, c.html, c.viewport, c.elements, c.captured_at);
|
|
|
|
db.prepare(`
|
|
DELETE FROM autobuyer_captures
|
|
WHERE id NOT IN (SELECT id FROM autobuyer_captures ORDER BY id DESC LIMIT ?)
|
|
`).run(CAPTURE_HISTORY);
|
|
}
|
|
|
|
export function getLatestCapture(): CaptureRow | undefined {
|
|
return db.prepare('SELECT * FROM autobuyer_captures ORDER BY id DESC LIMIT 1').get() as CaptureRow | undefined;
|
|
}
|
|
|
|
export function clearCaptures(): void {
|
|
db.prepare('DELETE FROM autobuyer_captures').run();
|
|
}
|
|
|
|
// ── AutoBuyer locate/click requests ──────────────────────────────────────────
|
|
//
|
|
// The Python clicker can't see inside Chrome, and the extension can't move the
|
|
// mouse. So they meet here: Python enqueues "where is <selector>?", the extension
|
|
// focuses the tab, measures the element and writes back desktop coordinates.
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS autobuyer_locate (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
selector TEXT NOT NULL,
|
|
match_index INTEGER NOT NULL DEFAULT 0,
|
|
url_pattern TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
result TEXT,
|
|
error TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
resolved_at INTEGER
|
|
);
|
|
`);
|
|
|
|
export interface LocateRow {
|
|
id: number;
|
|
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;
|
|
created_at: number;
|
|
resolved_at: number | null;
|
|
}
|
|
|
|
const LOCATE_HISTORY = 20;
|
|
|
|
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, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now());
|
|
|
|
db.prepare(`
|
|
DELETE FROM autobuyer_locate
|
|
WHERE id NOT IN (SELECT id FROM autobuyer_locate ORDER BY id DESC LIMIT ?)
|
|
`).run(LOCATE_HISTORY);
|
|
|
|
return db.prepare('SELECT * FROM autobuyer_locate WHERE id = ?').get(res.lastInsertRowid) as LocateRow;
|
|
}
|
|
|
|
export function getLocateRequest(id: number): LocateRow | undefined {
|
|
return db.prepare('SELECT * FROM autobuyer_locate WHERE id = ?').get(id) as LocateRow | undefined;
|
|
}
|
|
|
|
export function countPendingLocate(): number {
|
|
const row = db.prepare("SELECT COUNT(*) AS n FROM autobuyer_locate WHERE status = 'pending'").get() as { n: number };
|
|
return row.n;
|
|
}
|
|
|
|
/** Hand the oldest pending request to the extension, marking it claimed so a
|
|
* slow round-trip doesn't cause the same click to be served twice. */
|
|
export function claimLocateRequest(): LocateRow | undefined {
|
|
const row = db.prepare("SELECT * FROM autobuyer_locate WHERE status = 'pending' ORDER BY id LIMIT 1").get() as LocateRow | undefined;
|
|
if (!row) return undefined;
|
|
db.prepare("UPDATE autobuyer_locate SET status = 'claimed' WHERE id = ?").run(row.id);
|
|
return { ...row, status: 'claimed' };
|
|
}
|
|
|
|
export function resolveLocateRequest(id: number, result: unknown | null, error: string | null): void {
|
|
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.9.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; }
|
|
}
|