Let the master dashboard drive this instance; drop the AutoBuyer switch

Three changes that ride together because they touch the same surface.

A control link to the master. lib/master-link.ts dials out to
<master>/ws/instance and holds it open, so the dashboard can trigger a trade or
queue an AutoBuyer run here. Commands call the same functions the local pages
call — runTrade, startScheduler, queueRun — so nothing reaches the broker by a
path the UI does not also use. Reports prefer the socket and fall back to the
existing POST, which keeps older masters working unchanged.

startMasterLink is idempotent, and that matters more than it looks: getClients()
is lazy and starts the link, so the first command re-enters it from inside a
command the live socket is waiting to ack. Tearing the socket down there loses
the ack and reports a trade that actually executed as a failure — which invites
pressing the button twice.

queueRun is lifted out of the automations route so the socket and HTTP share one
copy of the input clamping that decides how many times a purchase fires.

Page capture loses its switch and gains a scope. It now runs every tick, but
only against the active tab of the last-focused window that matches
host_permissions — so it follows the page in front of you and stops when you
switch away, where before it would fall back to any firm tab left open in the
background.

autobuyer_enabled is gone entirely. The AutoBuyer is ready whenever the runner
is; a run happens because someone asked for one. That does mean a queued run
executes as soon as clicker/runner.py claims it, with no second gate — stopping
the runner is now the way to make a machine idle.

Extension to 0.10.0; it needs a manual reload at chrome://extensions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 22:31:31 -05:00
co-authored by Claude Opus 5
parent 0ba27161ce
commit 25289cb9b3
16 changed files with 586 additions and 190 deletions
+43 -2
View File
@@ -79,6 +79,38 @@ Open the app in your browser and add your firms through the UI:
---
## The master dashboard
Set **Instance Name** and **Dashboard URL** on the Settings page and this
instance does two things with them: it reports its firm and account counts every
30 seconds, and it holds a WebSocket open to `<url>/ws/instance` so the master
can drive it.
What the master can trigger here:
| | |
|---|---|
| Trade | Buy / Sell / Auto on a symbol, with the same "stop after all" option as the button on this dashboard |
| Stop | Halts the auto-trade scheduler |
| Run an automation | Queues a run for `clicker/runner.py`, exactly as the AutoBuyer page does |
Commands land in [`lib/master-link.ts`](lib/master-link.ts), which calls the same
functions the pages call — `runTrade`, `startScheduler`, `queueRun`. Nothing
reaches the broker by a path the local UI does not also use, and every command
is acked with its result or its error so the master shows what actually happened
rather than assuming.
The link is entirely optional. With no Dashboard URL configured it never starts,
and if the master is unreachable the instance keeps trading on its own and falls
back to the plain HTTP report until the socket comes back (retrying with backoff,
capped at a minute).
**Nothing authenticates this link in either direction.** Whatever answers at the
Dashboard URL can place orders on every account this instance holds, so point it
only at a master you control, and prefer `https` so the address cannot be
redirected in transit. If the socket will not stay up, the `connected` light on
the master is the only symptom — check this instance's logs for `[master-link]`.
## AutoBuyer (browser automation)
The AutoBuyer page drives a real browser to buy and reset prop-firm accounts. It
@@ -136,8 +168,17 @@ dashboard warns when its version is behind.
### Running it
Turn **Page capture** on from the AutoBuyer page, then press an automation's
button. Progress appears per step, and **Stop** halts a run between steps.
Press an automation's button. Progress appears per step, and **Stop** halts a run
between steps.
There is no switch to throw first. Page capture follows whichever firm tab you
are looking at and stops when you switch away, and a run starts because someone
asked for one — from this page or from the master dashboard. The only thing that
gates a run is whether `clicker/runner.py` is up to claim it, which the page
shows as **Autoclicker daemon**.
That does mean a queued run executes as soon as the runner sees it, with no
second confirmation. Stop the runner if you want the machine idle.
The browser must be visible and frontmost while a run is in flight — the clicks
are real OS-level input, so the machine cannot be used for anything else, and a
+5 -31
View File
@@ -1,6 +1,6 @@
import { NextRequest } from 'next/server';
import { FIRMS, findAutomation, automationKey, describeStep, resolveSteps } from '@/lib/automations';
import { createRun, getSetting } from '@/lib/db';
import { FIRMS, automationKey, describeStep } from '@/lib/automations';
import { queueRun, QueueRunError } from '@/lib/queue-run';
import { corsJson, corsPreflight } from '../cors';
/** The dashboard renders a tab per firm, and a button per automation inside it. */
@@ -30,38 +30,12 @@ export async function POST(req: NextRequest) {
if (typeof body.automationId !== 'string') {
return corsJson({ error: '`automationId` is required' }, { status: 400 });
}
const found = findAutomation(body.automationId);
if (!found) {
return corsJson({ error: `No automation "${body.automationId}"` }, { status: 404 });
}
if (found.automation.steps.length === 0) {
return corsJson({ error: `"${found.automation.label}" has no steps yet` }, { status: 400 });
}
// The dashboard switch is the master arm for anything that drives the mouse.
if (getSetting('autobuyer_enabled') !== '1') {
return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 });
}
// 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);
return corsJson(queueRun(body.automationId, (body.inputs ?? {}) as Record<string, unknown>));
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Could not expand the steps' }, { status: 400 });
if (err instanceof QueueRunError) {
return corsJson({ error: err.message }, { status: err.status });
}
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 });
}
}
+1 -5
View File
@@ -1,14 +1,10 @@
import { claimRun, getSetting } from '@/lib/db';
import { claimRun } from '@/lib/db';
import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations';
import { corsJson, corsPreflight } from '../../cors';
/** The Python runner polls this. Returns the run plus the steps to execute, so
* the runner never needs its own copy of the automation definitions. */
export async function POST() {
if (getSetting('autobuyer_enabled') !== '1') {
return corsJson({ run: null, reason: 'AutoBuyer is switched off' });
}
const row = claimRun();
if (!row) return corsJson({ run: null });
+4 -22
View File
@@ -1,29 +1,11 @@
import { NextRequest } from 'next/server';
import { getSetting, setSetting, countPendingLocate } from '@/lib/db';
import { countPendingLocate } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
* `pendingLocate` rides along so the extension learns about queued clicks without
* a second request on every tick. */
* The AutoBuyer has no on/off switch — it is ready whenever the runner is — so
* the only thing worth reporting here is whether clicks are waiting to be served. */
export async function GET() {
return corsJson({
enabled: getSetting('autobuyer_enabled') === '1',
pendingLocate: countPendingLocate(),
});
}
/** Flipped by the ON/OFF button on the AutoBuyer page. */
export async function PATCH(req: NextRequest) {
try {
const body = await req.json() as { enabled?: unknown };
if (typeof body.enabled !== 'boolean') {
return corsJson({ error: '`enabled` must be a boolean' }, { status: 400 });
}
setSetting('autobuyer_enabled', body.enabled ? '1' : '0');
return corsJson({ enabled: body.enabled });
} catch (err: any) {
return corsJson({ error: err?.message ?? 'Failed to set status' }, { status: 500 });
}
return corsJson({ pendingLocate: countPendingLocate() });
}
export async function OPTIONS() {
+8 -54
View File
@@ -115,9 +115,7 @@ function withBaseTag(html: string, url: string): string {
}
export default function AutoBuyer() {
const [enabled, setEnabled] = useState(false);
const [capture, setCapture] = useState<Capture | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<'rendered' | 'source'>('source');
// The extension posts a new capture every few seconds, and re-feeding srcDoc would
@@ -137,10 +135,6 @@ export default function AutoBuyer() {
const poll = useCallback(async () => {
try {
const s = await fetch('/api/autobuyer/status', { cache: 'no-store' }).then((r) => r.json());
setEnabled(!!s.enabled);
if (!s.enabled) return;
const url = lastAtRef.current
? `/api/autobuyer/capture?since=${lastAtRef.current}`
: '/api/autobuyer/capture';
@@ -236,24 +230,6 @@ export default function AutoBuyer() {
await pollRuns();
}
async function toggle() {
setBusy(true);
try {
const res = await fetch('/api/autobuyer/status', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !enabled }),
});
const data = await res.json();
setEnabled(!!data.enabled);
setError(null);
} catch (err: any) {
setError(err?.message ?? 'Failed to toggle');
} finally {
setBusy(false);
}
}
function showRendered() {
setView('rendered');
if (capture) setPinned(capture);
@@ -273,28 +249,11 @@ export default function AutoBuyer() {
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-slate-900 mb-6">AutoBuyer</h1>
{/* ── Capture switch and daemon status ── */}
{/* ── Daemon status ──
There is no on/off switch. Page capture follows whichever firm
tab you are looking at, and the AutoBuyer is ready whenever the
daemon below is up — so that row is the whole of the state. */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-6">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Page capture</p>
<p className="text-xs text-slate-400 mt-0.5">
While on, the AutoFirmer Capture extension posts the target tab&apos;s HTML here every few seconds
</p>
</div>
<button
onClick={toggle}
disabled={busy}
className={`text-sm px-4 py-1.5 rounded-lg font-medium transition-colors disabled:opacity-50 ${
enabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-slate-200 hover:bg-slate-300 text-slate-700'
}`}
>
{busy ? '…' : enabled ? 'ON' : 'OFF'}
</button>
</div>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Autoclicker daemon</p>
@@ -411,10 +370,9 @@ export default function AutoBuyer() {
))}
<button
onClick={() => startRun(a)}
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
disabled={busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
title={
!enabled ? 'AutoBuyer is switched off'
: !runner?.online ? 'The runner is not running'
!runner?.online ? 'The runner is not running'
: runner?.stale ? 'The runner is out of date — restart it'
: activeRun !== null ? 'A run is already in progress'
: undefined
@@ -484,13 +442,9 @@ export default function AutoBuyer() {
Latest capture
</h2>
{!enabled ? (
{!capture ? (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
Capture is off
</div>
) : !capture ? (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
Waiting for the extension… open a page in Chrome with the extension installed
Waiting for the extension… switch to a firm tab in the Chrome window that has it installed
</div>
) : (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
+2 -1
View File
@@ -201,7 +201,8 @@ export default function SettingsPage() {
<div>
<p className="text-sm font-medium text-slate-800">Dashboard URL</p>
<p className="text-xs text-slate-400 mt-0.5">
Reports state to this URL every 30 seconds
Reports state here every 30 seconds, and holds a control
socket open so the dashboard can trigger trades and buys
</p>
</div>
<input
+23 -15
View File
@@ -1,7 +1,8 @@
# AutoFirmer Capture (Chromium extension)
Polls the dashboard for an on/off flag and, while it's on, scrapes the target
tab's HTML and posts it back to the dashboard.
Scrapes the firm tab you are looking at and posts its HTML back to the
dashboard. There is no on/off switch for that — see **Scope** below for what
limits it instead.
## Install (unpacked)
@@ -12,34 +13,41 @@ tab's HTML and posts it back to the dashboard.
- **Dashboard URL** — default `http://localhost:3000`. Use the VPS IP if the
dashboard runs elsewhere.
- **Poll interval** — seconds between status checks (default 3).
- **Target URL pattern** — a Chrome match pattern like
`https://*.tradovate.com/*`. Leave blank to capture whichever tab is active.
- **Element selector** — optional CSS selector; matching elements get their
on-screen position measured alongside the HTML.
The badge shows `ON` (green) while capturing, `!` (red) if the dashboard is
unreachable, and nothing when the switch is off.
The badge shows `!` (red) if the dashboard is unreachable, and nothing otherwise
— blank is the working state. Open the popup for what the last tick actually did.
## Flow
```
AutoBuyer page --PATCH /api/autobuyer/status--> SQLite settings
extension --GET /api/autobuyer/status--> { enabled }
extension --GET /api/autobuyer/status--> { pendingLocate }
extension --POST /api/autobuyer/capture-> html + viewport + element rects
AutoBuyer page --GET /api/autobuyer/capture-> renders the HTML
```
Nothing in that loop is switchable. Every tick serves any queued clicks and then
captures the tab you are on.
## Scope
`host_permissions` is deliberately narrow — `https://*.tradeify.co/*` plus localhost
for the dashboard. The extension is technically incapable of reading any other site,
so an accidental capture of your bank or mail tab can't happen. The default
**Target URL pattern** matches, so it only ever captures the broker tab regardless
of which tab is focused.
Capture has no switch, so scope is what keeps it honest. Two limits, both
enforced by Chrome rather than by this code:
1. **`host_permissions` is deliberately narrow** — the firm sites plus localhost
for the dashboard. The extension is *technically incapable* of reading any
other site, so an accidental capture of your bank or mail tab cannot happen
however the logic here is changed.
2. **Only the tab you are on.** `pickTab()` asks for the active tab of the
last-focused window *and* a host_permissions match. A firm tab sitting in the
background is not captured, and neither is anything else. Switch away and
capture simply stops until you switch back.
To automate a different broker, add its pattern to `host_permissions` in
`manifest.json`, update the popup's target pattern, and reload the extension. Avoid
going back to `<all_urls>` — that re-enables scraping whatever tab is active.
`manifest.json` and reload the extension — that alone scopes capture to it. Avoid
going back to `<all_urls>`: it would put every tab you visit in reach, and limit
1 is the only thing standing between this and scraping your whole browser.
## Notes
+37 -28
View File
@@ -1,8 +1,15 @@
/* AutoFirmer Capture — MV3 service worker.
*
* Loop: poll GET /api/autobuyer/status. While `enabled` is true, scrape the
* target tab's HTML and POST it to /api/autobuyer/capture. The dashboard's
* AutoBuyer page renders whatever landed there.
* Loop: every tick, scrape the tab you are looking at and POST it to
* /api/autobuyer/capture. The dashboard's AutoBuyer page renders whatever
* landed there.
*
* Nothing here has a switch. Capture is scoped instead: only the ACTIVE tab of
* the last-focused window, and only when that tab is one of the firm sites in
* host_permissions. Sit on any other page and nothing is read or sent.
*
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
* ready at all times; a run happens because someone asked for one.
*/
const DEFAULTS = {
@@ -31,9 +38,11 @@ async function setState(patch) {
await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } });
}
function setBadge(connected, enabled) {
chrome.action.setBadgeText({ text: !connected ? '!' : enabled ? 'ON' : '' });
chrome.action.setBadgeBackgroundColor({ color: !connected ? '#ef4444' : '#22c55e' });
/** `!` when the dashboard is unreachable, blank otherwise. A blank badge is the
* working state — there is nothing to switch on, so nothing to advertise. */
function setBadge(connected) {
chrome.action.setBadgeText({ text: connected ? '' : '!' });
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
}
// ── The injected scraper ────────────────────────────────────────────────────
@@ -106,20 +115,26 @@ function firmPatterns() {
.filter((p) => !/\/\/(localhost|127\.0\.0\.1)/.test(p));
}
async function pickTab(cfg) {
const tabs = (await chrome.tabs.query({ url: firmPatterns() }))
.filter((t) => /^https?:/.test(t.url || ''));
if (tabs.length === 0) return undefined;
// Prefer the one being looked at, so with two firms open the capture follows
// attention rather than tab order.
return tabs.find((t) => t.active) ?? tabs[0];
/** The firm tab you are actually looking at, or nothing.
*
* Deliberately no fallback to "some other firm tab that happens to be open":
* capture should report the page in front of you, not a stale background tab
* you left on a broker site hours ago. Chrome does the pattern matching, so a
* tab outside host_permissions cannot be selected here at all.
*/
async function pickTab() {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
url: firmPatterns(),
});
return tab && /^https?:/.test(tab.url || '') ? tab : undefined;
}
async function captureAndSend(cfg) {
const tab = await pickTab(cfg);
const tab = await pickTab();
if (!tab) {
await setState({ connected: true, enabled: true, error: null, waiting: 'No eligible tab to capture' });
await setState({ connected: true, error: null, waiting: 'Not on a firm page — nothing captured' });
return;
}
@@ -147,7 +162,6 @@ async function captureAndSend(cfg) {
await setState({
connected: true,
enabled: true,
error: null,
waiting: null,
lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() },
@@ -454,24 +468,19 @@ async function serveLocateRequest(cfg) {
async function tick() {
const cfg = await getConfig();
let enabled;
try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const status = await res.json();
enabled = !!status.enabled;
pendingLocate = Number(status.pendingLocate) || 0;
} catch (err) {
setBadge(false, false);
await setState({ connected: false, enabled: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
setBadge(false);
await setState({ connected: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
return;
}
setBadge(true, enabled);
if (!enabled) {
await setState({ connected: true, enabled: false, error: null, waiting: null });
return;
}
setBadge(true);
await setState({ connected: true });
// Serve queued clicks before capturing — the Python side is waiting on these,
// and a capture of a megabyte page shouldn't sit in front of them.
@@ -482,13 +491,13 @@ async function tick() {
if (!(await serveLocateRequest(cfg))) break;
}
} catch (err) {
await setState({ connected: true, enabled: true, error: `Locate queue: ${err.message}` });
await setState({ connected: true, error: `Locate queue: ${err.message}` });
}
try {
await captureAndSend(cfg);
} catch (err) {
await setState({ connected: true, enabled: true, error: err.message });
await setState({ connected: true, error: err.message });
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.8.0",
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
"version": "0.10.0",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [
"https://*.tradeify.co/*",
+5 -2
View File
@@ -13,8 +13,11 @@ async function load() {
async function render() {
const { state = {} } = await chrome.storage.local.get('state');
const color = !state.connected ? '#ef4444' : state.enabled ? '#22c55e' : '#94a3b8';
const label = !state.connected ? 'Disconnected' : state.enabled ? 'ON — capturing' : 'Connected — off';
// There is nothing to switch on: either it can reach the dashboard or it
// cannot. `waiting` below says why a tick captured nothing.
const color = state.connected ? '#22c55e' : '#ef4444';
const label = state.connected ? 'Capturing' : 'Disconnected';
const cap = state.lastCapture;
const detail = state.waiting
+6 -1
View File
@@ -1,7 +1,8 @@
import { TradovateClient } from './tradovate-class';
import { getFirms, getInstruments } from './db';
import { resolveContracts } from './contract-resolver';
import { startReporter } from './reporter';
import { startReporter, sendReport } from './reporter';
import { startMasterLink } from './master-link';
import { resumeSchedulerIfPersisted } from './auto-trade';
// Use global to persist the client pool across HMR reloads in dev mode
@@ -65,6 +66,10 @@ export function getClients(): Map<number, TradovateClient> {
// Start master dashboard reporter
startReporter();
// ...and the socket it prefers to report over, which is also how
// the master triggers trades and AutoBuyer runs here.
startMasterLink(() => { void sendReport(); });
// Pick the auto-trade schedule back up if it was running before restart
resumeSchedulerIfPersisted();
} catch (err) {
-2
View File
@@ -341,8 +341,6 @@ db.exec(`
);
`);
seedSetting.run('autobuyer_enabled', '0');
export interface CaptureRow {
id: number;
url: string;
+303
View File
@@ -0,0 +1,303 @@
/**
* Control link to the master dashboard.
*
* We dial out, not the other way round: this machine sits on a home network
* behind NAT and the master is the only end with a public address. Once the
* socket is up it carries traffic both ways — reports go up it instead of the
* 30s HTTP POST, and trade / AutoBuyer commands come back down it.
*
* The link is optional. Without `master_dashboard_url` and `instance_name`
* nothing starts, and if the master is unreachable the reporter falls back to
* the HTTP endpoint it has always used.
*
* Neither end authenticates the other. Whatever is at `master_dashboard_url`
* can place orders on every account this instance holds, so point it only at a
* master you control, and prefer https so it cannot be redirected in transit.
*/
import { getSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
import { FIRMS, automationKey } from './automations';
import { POINT_VALUES } from './trading-logic';
import { queueRun } from './queue-run';
import type {
Command,
InstanceCapabilities,
InstanceMessage,
MasterMessage,
} from './master-protocol';
const WS_PATH = '/ws/instance';
const RECONNECT_MIN_MS = 2_000;
const RECONNECT_MAX_MS = 60_000;
interface LinkState {
socket: WebSocket | null;
/** Only true once the master has answered the hello. */
ready: boolean;
attempt: number;
reconnectTimer: ReturnType<typeof setTimeout> | null;
stopped: boolean;
}
// HMR-safe: a dev reload must not leave a second socket dialling.
const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
function state(): LinkState {
if (!_g.__masterLink) {
_g.__masterLink = { socket: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
}
return _g.__masterLink;
}
// ── capabilities ─────────────────────────────────────────────────────────────
/** What this instance can be asked to do, as of right now. */
export function collectCapabilities(): InstanceCapabilities {
// Only symbols the operator has enabled here. An empty instruments table
// means nothing has been configured yet, so offer everything we can price.
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
const heartbeat = getRunnerHeartbeat();
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
return {
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
automations: FIRMS.flatMap((firm) =>
firm.automations
.filter((a) => a.steps.length > 0)
.map((a) => ({
key: automationKey(firm.id, a.id),
label: a.label,
firm: firm.label,
inputs: (a.inputs ?? []).map((i) => ({
id: i.id,
label: i.label,
min: i.min,
max: i.max,
default: i.default,
})),
}))
),
runnerOnline,
};
}
// ── commands ─────────────────────────────────────────────────────────────────
/**
* Runs one command and returns whatever the master should show the operator.
* Throwing is fine — the caller turns it into a failed ack, which surfaces as
* a red banner on the dashboard rather than silence.
*/
async function execute(command: Command): Promise<unknown> {
switch (command.kind) {
case 'trade': {
if (command.symbol !== 'Auto' && !POINT_VALUES[command.symbol]) {
throw new Error(`Unknown symbol: ${command.symbol}`);
}
// Same order as POST /api/trade: fire once now, then leave the
// scheduler running to pick up accounts that were busy.
const results = await runTrade(command.action, command.symbol);
startScheduler(command.action, command.symbol, command.stopAfterAll ?? false);
const filled = results
.flatMap((r: any) => r.results ?? [])
.filter((r: any) => r.status === 'filled').length;
return { filled, scheduler: getSchedulerStatus() };
}
case 'stop-trading':
stopScheduler();
return { stopped: true, scheduler: getSchedulerStatus() };
case 'autobuyer-run':
return queueRun(command.automationId, command.inputs ?? {});
case 'report-now':
return { reported: true };
default: {
// A newer master may know commands this build does not. Say so
// rather than failing silently.
const unknown = command as { kind?: string };
throw new Error(`Unsupported command: ${unknown.kind ?? 'unnamed'}`);
}
}
}
// ── link ─────────────────────────────────────────────────────────────────────
/** `https://dash.example.com` → `wss://dash.example.com/ws/instance` */
function toWsUrl(masterUrl: string): string | null {
try {
const url = new URL(masterUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = WS_PATH;
url.search = '';
url.hash = '';
return url.toString();
} catch {
return null;
}
}
function send(message: InstanceMessage): boolean {
const s = state();
if (!s.socket || !s.ready || s.socket.readyState !== WebSocket.OPEN) return false;
try {
s.socket.send(JSON.stringify(message));
return true;
} catch {
return false;
}
}
/** True when the report went up the socket, so the caller can skip the POST. */
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
return send({
type: 'report',
firms: firms as never,
scheduler: scheduler as never,
capabilities: collectCapabilities(),
});
}
export function isLinked(): boolean {
return state().ready;
}
function scheduleReconnect(reason: string) {
const s = state();
if (s.stopped || s.reconnectTimer) return;
// Exponential backoff with jitter: a master restart brings every instance
// back at once, and they should not all land on the same millisecond.
const base = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** s.attempt);
const delay = Math.round(base * (0.5 + Math.random() / 2));
s.attempt++;
if (s.attempt <= 3 || s.attempt % 10 === 0) {
console.log(`[master-link] ${reason} — retrying in ${Math.round(delay / 1000)}s`);
}
s.reconnectTimer = setTimeout(() => {
s.reconnectTimer = null;
connect();
}, delay);
}
function connect() {
const s = state();
if (s.stopped || s.socket) return;
const masterUrl = getSetting('master_dashboard_url');
const instance = getSetting('instance_name');
if (!masterUrl || !instance) {
// Not configured — check again later in case Settings gets filled in.
scheduleReconnect('not configured');
return;
}
const url = toWsUrl(masterUrl);
if (!url) {
scheduleReconnect(`master_dashboard_url is not a URL: ${masterUrl}`);
return;
}
let socket: WebSocket;
try {
socket = new WebSocket(url);
} catch (err: any) {
scheduleReconnect(err?.message ?? 'could not open socket');
return;
}
s.socket = socket;
s.ready = false;
socket.addEventListener('open', () => {
socket.send(JSON.stringify({
type: 'hello',
instance,
capabilities: collectCapabilities(),
} satisfies InstanceMessage));
});
socket.addEventListener('message', (event) => {
let msg: MasterMessage;
try {
msg = JSON.parse(String(event.data));
} catch {
return;
}
if (msg.type === 'welcome') {
s.ready = true;
s.attempt = 0;
console.log(`[master-link] connected to ${url} as "${instance}"`);
return;
}
if (msg.type !== 'command') return;
const { id, command } = msg;
console.log(`[master-link] command: ${command.kind}`);
// Deliberately not awaited: a trade walks every account and can take a
// while, and the socket must stay free for a Stop in the meantime.
void (async () => {
try {
const result = await execute(command);
send({ type: 'ack', id, ok: true, result });
} catch (err: any) {
console.error(`[master-link] command ${command.kind} failed:`, err?.message ?? err);
send({ type: 'ack', id, ok: false, error: err?.message ?? String(err) });
}
// Whatever just happened, the dashboard's numbers are now stale.
reportSoon();
})();
});
const dropped = (reason: string) => {
if (s.socket !== socket) return; // superseded
s.socket = null;
s.ready = false;
scheduleReconnect(reason);
};
socket.addEventListener('close', (event) => {
dropped(event.reason ? `closed: ${event.reason}` : 'connection closed');
});
socket.addEventListener('error', () => {
// 'close' always follows, and carries the better message of the two.
if (s.socket === socket && s.ready) console.warn('[master-link] socket error');
});
}
/** Set by startMasterLink so a command can trigger a report without a cycle. */
let reportSoon: () => void = () => { };
export function startMasterLink(onReportRequested: () => void) {
if (typeof WebSocket === 'undefined') {
console.warn('[master-link] no global WebSocket in this Node build — control link disabled');
return;
}
reportSoon = onReportRequested;
const s = state();
s.stopped = false;
// Idempotent on purpose. getClients() is lazy and starts this link, so the
// first command that reaches it re-enters here from *inside* a command the
// live socket is waiting to ack. Tearing the socket down there loses the
// ack, and the master reports a trade that actually executed as a failure —
// which invites pressing the button a second time. Already dialling or
// connected means there is nothing to do.
if (s.socket || s.reconnectTimer) return;
connect();
console.log('[master-link] started');
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Wire protocol for the control link to the master dashboard.
*
* Mirror of the block at the bottom of `autotrader-master/types.ts`, which is
* the source of truth. The two repos deploy independently, so change both
* together — and only ever by adding, since an instance and a master can be
* running different versions of this file at the same time.
*/
export interface FirmStats {
firm: string;
totalAccounts: number;
accountsTraded: number;
inTrade: number;
}
export interface SchedulerState {
running: boolean;
action: 'Buy' | 'Sell' | 'Auto';
symbol: string;
lastRun: string | null;
intervalSeconds: number;
stopAfterAll?: boolean;
}
export interface AutomationSummary {
key: string;
label: string;
firm: string;
inputs: { id: string; label: string; min: number; max: number; default: number }[];
}
export interface InstanceCapabilities {
symbols: string[];
automations: AutomationSummary[];
runnerOnline: boolean;
}
export type Command =
| { kind: 'trade'; action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean }
| { kind: 'stop-trading' }
| { kind: 'autobuyer-run'; automationId: string; inputs?: Record<string, number> }
| { kind: 'report-now' };
export type MasterMessage =
| { type: 'welcome' }
| { type: 'command'; id: string; command: Command };
export type InstanceMessage =
| { type: 'hello'; instance: string; capabilities: InstanceCapabilities }
| { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; capabilities?: InstanceCapabilities }
| { type: 'ack'; id: string; ok: boolean; result?: unknown; error?: string };
+56
View File
@@ -0,0 +1,56 @@
/**
* Queuing an AutoBuyer run.
*
* Lifted out of POST /api/autobuyer/automations so the master dashboard's
* control link can queue the same run over its WebSocket without going back
* through HTTP — and, more to the point, without a second copy of the checks
* that decide how many times a purchase fires.
*
* Queuing is always allowed. Nothing arms the AutoBuyer any more, so the only
* thing standing between a queued run and a moving mouse is whether
* clicker/runner.py is up to claim it.
*/
import { createRun } from './db';
import { findAutomation, resolveSteps } from './automations';
export class QueueRunError extends Error {
constructor(message: string, readonly status: number) {
super(message);
this.name = 'QueueRunError';
}
}
export interface QueuedRun {
runId: number;
totalSteps: number;
inputs: Record<string, number>;
}
export function queueRun(automationId: string, supplied: Record<string, unknown> = {}): QueuedRun {
const found = findAutomation(automationId);
if (!found) {
throw new QueueRunError(`No automation "${automationId}"`, 404);
}
if (found.automation.steps.length === 0) {
throw new QueueRunError(`"${found.automation.label}" has no steps yet`, 400);
}
// 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 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) {
throw new QueueRunError(err?.message ?? 'Could not expand the steps', 400);
}
const run = createRun(automationId, resolved.length, inputs);
return { runId: run.id, totalSteps: run.total_steps, inputs };
}
+24 -10
View File
@@ -1,11 +1,18 @@
/**
* Reporter module — pushes aggregated state to the master dashboard.
*
* Every 30 seconds, collects firm-level stats + scheduler state and POSTs
* Every 30 seconds, collects firm-level stats + scheduler state and sends them
* to the configured master_dashboard_url. Silently skips if not configured.
*
* Two transports, in that order of preference: the control WebSocket in
* `master-link.ts` when it is up, and the original POST /api/report when it is
* not. The socket already exists for commands, so reusing it saves a request
* per tick — but a master on an older build has no socket to offer, and the
* POST keeps those working unchanged.
*/
import { getSetting } from './db';
import { pushReport } from './master-link';
import { getFirms } from './db';
import { getClients } from './clients';
import { getSchedulerStatus } from './auto-trade';
@@ -69,12 +76,12 @@ function collectFirmStats() {
// HMR-safe global to avoid duplicate intervals
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
export function startReporter() {
if (_g.__reporterInterval) {
clearInterval(_g.__reporterInterval);
}
const report = async () => {
/**
* Collect and send one report. Exported so the control link can fire an
* off-cycle one the moment a command has changed something — waiting up to 30s
* to see whether your own button worked is the wrong feedback loop.
*/
export async function sendReport(): Promise<void> {
const url = getSetting('master_dashboard_url');
const instanceName = getSetting('instance_name');
@@ -84,6 +91,8 @@ export function startReporter() {
const firms = collectFirmStats();
const scheduler = getSchedulerStatus();
if (pushReport(firms, scheduler)) return;
await fetch(`${url}/api/report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -92,12 +101,17 @@ export function startReporter() {
} catch {
// Master might be down — silently continue
}
};
}
_g.__reporterInterval = setInterval(report, 30_000);
export function startReporter() {
if (_g.__reporterInterval) {
clearInterval(_g.__reporterInterval);
}
_g.__reporterInterval = setInterval(sendReport, 30_000);
// Fire first report after a short delay to let clients sync
setTimeout(report, 10_000);
setTimeout(sendReport, 10_000);
console.log('[reporter] started — reporting every 30s');
}