focus.py now verifies on Windows rather than assuming activation worked. Windows declines to raise a window for a process that doesn't own the foreground — it flashes the taskbar and the call returns as if it succeeded — so the runner reads the foreground window's process back and reports a failure instead of clicking into a background window. Same gap that was fixed on macOS earlier. The runner also declares itself DPI-aware at startup. Without it Windows reports a virtualised screen size and rescales the coordinates it accepts, while the browser keeps reporting CSS pixels; on a display at 125% or 150% the two disagree and clicks drift further off the further they are from the top-left. Browser windows are matched on the owning process rather than the window title, so an editor with chrome.js open is no longer mistaken for the browser. Linux now says window management is unsupported there, rather than reporting no browser found — pygetwindow has no X11 backend, and "no browser window" reads like Chrome is shut. The main README gained a section on the AutoBuyer: what the three pieces are, how to load the extension, and that neither the extension nor the runner reloads itself when the source changes. That last point has been the cause of most of the confusing failures so far, so it is called out in Updating too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
627 lines
25 KiB
TypeScript
627 lines
25 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 '[]',
|
|
inputs 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
|
|
}
|
|
|
|
// 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 {
|
|
id: number;
|
|
automation_id: string;
|
|
status: RunStatus;
|
|
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;
|
|
}
|
|
|
|
const RUN_HISTORY = 30;
|
|
|
|
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, inputs, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
|
).run(automationId, totalSteps, JSON.stringify(inputs), 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;
|
|
}
|
|
|
|
/** 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;
|
|
|
|
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.12.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; }
|
|
}
|