Backups to master, replacement-VPS restore, broker proxy, multi-instance

Four features:

- Backup: lib/backup-sync.ts pushes the full settings table and firm
  credentials (the deliberate exception to "credentials never leave the
  machine") to the master's bearer-gated /api/instances/backup — every
  5 minutes when the content hash changed, within seconds of a settings
  or credential edit, and on the new backup-now command. Enabled by the
  master_backup_token setting.

- Restore: a dead VPS's replacement pulls the backup and adopts its
  uuid/name, so roster, payouts and card links carry on. Pre-boot via
  seed-settings.js --restore-from (setup-linux.sh prompts for it) or
  the Settings page's restore picker (lib/backup-restore.ts). Stop the
  old machine first; the master 409s a live-name rename.

- Proxy: the proxy_url setting routes BROKER traffic only — TopstepX
  REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via
  lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent).
  The master link, reporter, catalog and backup push stay direct, so a
  dead proxy cannot take down the control channel. Restart to apply.

- Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout
  runs N instances; scripts/instances.json (per-device, gitignored)
  drives start-all/update-check, and add-instance.sh / remove-instance.sh
  manage the extra PM2 processes. Absent registry = single-instance
  behavior, unchanged. Instance 1 keeps port 3000 for the clicker and
  extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-22 13:55:35 -05:00
co-authored by Claude Fable 5
parent 97947bffde
commit 8154f25bb9
25 changed files with 1396 additions and 76 deletions
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
#
# Register an additional autofirmer instance on this device: same checkout,
# same build, its own PM2 process, port and database.
#
# ./scripts/add-instance.sh <N> [--name <instance_name>] [--master <url>]
# [--port <port>] [--token <backup_token>]
# [--restore-from <instance_uuid>]
#
# N instance number, 2 or higher (1 is the legacy install:
# pm2 name `autofirmer`, port 3000, ./autotrader.sqlite —
# registered by install-autostart.sh, never by this script)
# --name instance_name to seed (default: <hostname>-N)
# --master master dashboard url to seed (default: leave the app's seed)
# --port listen port (default: 3000 + N - 1; NOTE the master dev
# server uses 3001 — pick another port on a dev machine)
# --token master_backup_token to seed
# --restore-from pull that instance's backup from the master before first
# boot and adopt its identity (requires --master and --token)
#
# The instance lands in scripts/instances.json, which start-all.mjs and
# update-check.mjs iterate. The first run of this script also records the
# legacy instance 1 there so updates keep restarting it.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REGISTRY="$ROOT/scripts/instances.json"
info() { printf ' %s\n' "$1"; }
die() { printf ' [X] %s\n' "$1" >&2; exit 1; }
[ $# -ge 1 ] || die "usage: $0 <N> [--name ...] [--master ...] [--port ...] [--token ...] [--restore-from ...]"
N="$1"; shift
case "$N" in (*[!0-9]*|'') die "N must be a number, got: $N" ;; esac
[ "$N" -ge 2 ] || die "N starts at 2 — instance 1 is the legacy install (install-autostart.sh)"
NAME="$(hostname)-$N"
MASTER=""
PORT=$((3000 + N - 1))
TOKEN=""
RESTORE_FROM=""
while [ $# -gt 0 ]; do
case "$1" in
--name) NAME="$2"; shift 2 ;;
--master) MASTER="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
--restore-from) RESTORE_FROM="$2"; shift 2 ;;
*) die "unknown option: $1" ;;
esac
done
PM2_NAME="autofirmer-$N"
DB_REL=".data/instance-$N/autotrader.sqlite"
DB_ABS="$ROOT/$DB_REL"
if [ -n "$RESTORE_FROM" ]; then
[ -n "$MASTER" ] || die "--restore-from needs --master"
[ -n "$TOKEN" ] || die "--restore-from needs --token"
fi
NEXT_BIN="$ROOT/node_modules/next/dist/bin/next"
[ -f "$NEXT_BIN" ] || die "next not found at $NEXT_BIN - run npm install first"
[ -d "$ROOT/.next" ] || die "no production build - run npm run build first"
command -v pm2 >/dev/null 2>&1 || die "PM2 not installed - run install-autostart.sh first"
# ── registry: create with the legacy entry, refuse duplicates, append ───────
node - "$REGISTRY" "$PM2_NAME" "$PORT" "$DB_REL" <<'NODE'
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
const [, file, name, port, db] = process.argv;
const entries = existsSync(file)
? JSON.parse(readFileSync(file, 'utf8'))
: [{ name: 'autofirmer', port: 3000, db: 'autotrader.sqlite' }];
for (const e of entries) {
if (e.name === name) { console.error(` [X] ${name} is already registered`); process.exit(1); }
if (e.port === Number(port)) { console.error(` [X] port ${port} is already used by ${e.name}`); process.exit(1); }
if (e.db === db) { console.error(` [X] db ${db} is already used by ${e.name}`); process.exit(1); }
}
entries.push({ name, port: Number(port), db });
writeFileSync(file, JSON.stringify(entries, null, 4) + '\n');
console.log(` registry: ${name} on :${port} -> ${db}`);
NODE
mkdir -p "$(dirname "$DB_ABS")"
# ── seed (and optionally restore) the new database ──────────────────────────
cd "$ROOT"
SEED_ARGS=()
[ -n "$TOKEN" ] && SEED_ARGS+=(--token "$TOKEN")
[ -n "$RESTORE_FROM" ] && SEED_ARGS+=(--restore-from "$RESTORE_FROM")
AUTOFIRMER_DB="$DB_ABS" node scripts/seed-settings.js "$MASTER" "$NAME" ${SEED_ARGS[@]+"${SEED_ARGS[@]}"}
# ── PM2 ─────────────────────────────────────────────────────────────────────
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
PORT="$PORT" AUTOFIRMER_DB="$DB_ABS" pm2 start "$NEXT_BIN" --name "$PM2_NAME" --interpreter node --update-env -- start
pm2 save >/dev/null
info "pm2: $PM2_NAME registered and saved"
echo
info "instance $N is up: http://localhost:$PORT"
info "database: $DB_REL"
if [ -n "$RESTORE_FROM" ]; then
info "restored from backup $RESTORE_FROM - stop the old machine if it is still running"
fi
info "remove later with: ./scripts/remove-instance.sh $N"
+44
View File
@@ -0,0 +1,44 @@
/**
* The per-device instance registry: which autofirmer processes run on this
* machine, on which ports, against which databases.
*
* scripts/instances.json is created and edited by add-instance.sh /
* remove-instance.sh. When the file is absent the device is a legacy
* single-instance install and everything behaves exactly as it always has:
* one PM2 process named `autofirmer` on port 3000 using ./autotrader.sqlite.
*
* Entry shape: { "name": "autofirmer-2", "port": 3100, "db": ".data/instance-2/autotrader.sqlite" }
* `db` is relative to the project root. Instance 1 keeps the legacy
* name/port/db so the clicker and extension (hardwired to :3000) still work.
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const REGISTRY_FILE = path.join(ROOT, 'scripts', 'instances.json');
const LEGACY = [{
name: 'autofirmer',
port: Number(process.env.AUTOFIRMER_PORT ?? '3000'),
db: 'autotrader.sqlite',
}];
export function loadInstances() {
if (!existsSync(REGISTRY_FILE)) return LEGACY;
let parsed;
try {
parsed = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
} catch (err) {
throw new Error(`scripts/instances.json is not valid JSON: ${err.message}`);
}
if (!Array.isArray(parsed) || parsed.length === 0) return LEGACY;
for (const e of parsed) {
if (typeof e?.name !== 'string' || !e.name
|| !Number.isInteger(e?.port)
|| typeof e?.db !== 'string' || !e.db) {
throw new Error(`scripts/instances.json entry is malformed: ${JSON.stringify(e)}`);
}
}
return parsed;
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Unregister an instance added by add-instance.sh: stops its PM2 process and
# drops it from scripts/instances.json. The database directory is left on
# disk — deleting credentials is a decision for a human with rm.
#
# ./scripts/remove-instance.sh <N>
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REGISTRY="$ROOT/scripts/instances.json"
info() { printf ' %s\n' "$1"; }
die() { printf ' [X] %s\n' "$1" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: $0 <N>"
N="$1"
case "$N" in (*[!0-9]*|'') die "N must be a number, got: $N" ;; esac
[ "$N" -ge 2 ] || die "instance 1 is the legacy install - remove it with pm2 delete autofirmer"
[ -f "$REGISTRY" ] || die "no scripts/instances.json - nothing to remove"
PM2_NAME="autofirmer-$N"
node - "$REGISTRY" "$PM2_NAME" <<'NODE'
const { readFileSync, writeFileSync } = require('node:fs');
const [, file, name] = process.argv;
const entries = JSON.parse(readFileSync(file, 'utf8'));
if (!entries.some((e) => e.name === name)) { console.error(` [X] ${name} is not registered`); process.exit(1); }
writeFileSync(file, JSON.stringify(entries.filter((e) => e.name !== name), null, 4) + '\n');
console.log(` registry: ${name} removed`);
NODE
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || info "pm2: $PM2_NAME was not running"
pm2 save >/dev/null
info "pm2: $PM2_NAME deleted and process list saved"
info "database left at .data/instance-$N/ - delete by hand if you mean it"
+153 -10
View File
@@ -1,31 +1,174 @@
/**
* Writes the reporter settings into autotrader.sqlite before first launch.
* Writes the reporter settings into the instance database before first launch,
* and optionally restores a full backup from the master.
*
* lib/db.ts seeds these keys with INSERT OR IGNORE, so values written here
* survive the app's own startup seeding. Must be run from the project root —
* lib/db.ts opens the database at process.cwd().
* lib/db.ts seeds its keys with INSERT OR IGNORE, so values written here
* survive the app's own startup seeding. Must be run from the project root
* unless AUTOFIRMER_DB points elsewhere — lib/db.ts opens the database at
* process.cwd() by default, and this script must hit the same file.
*
* node scripts/seed-settings.js <master_dashboard_url> <instance_name>
* node scripts/seed-settings.js <master_dashboard_url> <instance_name> \
* [--token <backup_token>] [--restore-from <instance_uuid>]
*
* --restore-from pulls that instance's encrypted backup from the master
* (authenticated by --token) and applies it — settings, firm credentials,
* instruments, account assignments — before the app has ever booted, so the
* very first hello to the master already presents the adopted uuid. The
* backup's instance_name wins over the CLI argument.
*/
const path = require('path');
const { mkdirSync } = require('fs');
const Database = require('better-sqlite3');
const [, , url, name] = process.argv;
const args = process.argv.slice(2);
const positional = [];
let token = '';
let restoreFrom = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--token') token = args[++i] ?? '';
else if (args[i] === '--restore-from') restoreFrom = args[++i] ?? '';
else positional.push(args[i]);
}
const [url, name] = positional;
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
const DB_PATH = process.env.AUTOFIRMER_DB
? path.resolve(process.env.AUTOFIRMER_DB)
: path.join(process.cwd(), 'autotrader.sqlite');
mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
// Mirrors the schema in lib/db.ts for every table a restore touches. The app
// has usually never run at this point, so the tables may not exist yet; the
// shapes must be kept in sync with lib/db.ts by hand.
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS firm_credentials (
firm_id TEXT PRIMARY KEY,
firm_name TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS instruments (
symbol TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS account_assignments (
account_id INTEGER PRIMARY KEY,
firm_id TEXT NOT NULL,
account_type_id TEXT,
manual INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT NOT NULL
);
`);
try {
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
} catch {
// Column already exists
}
const upsert = db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
);
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
if (token) { upsert.run('master_backup_token', token); console.log(' master_backup_token = (set)'); }
db.close();
// Runtime bookkeeping keys that describe the machine the backup was taken on,
// not the one being provisioned. Kept in sync with lib/backup-restore.ts.
const RESTORE_SKIP_KEYS = new Set([
'runner_heartbeat',
'catalog_last_sync_at',
'catalog_last_error',
'backup_last_push_at',
'backup_last_error',
]);
async function restore() {
if (!restoreFrom) return;
if (!url) { console.error('--restore-from needs the master url argument'); process.exit(1); }
if (!token) { console.error('--restore-from needs --token'); process.exit(1); }
const endpoint = new URL(`/api/instances/backup/${restoreFrom}`, url);
console.log(` restoring from ${endpoint}`);
const res = await fetch(endpoint, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
console.error(` restore failed: master answered ${res.status} ${await res.text().catch(() => '')}`);
process.exit(1);
}
const payload = await res.json();
if (payload?.v !== 1 || typeof payload.settings !== 'object') {
console.error(' restore failed: unrecognized backup payload');
process.exit(1);
}
const setCred = db.prepare(`
INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(firm_id) DO UPDATE SET
firm_name = excluded.firm_name,
username = excluded.username,
password = excluded.password,
api_key = excluded.api_key,
updated_at = excluded.updated_at
`);
const setInstr = db.prepare(
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
);
const setAssign = db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at
`);
db.transaction(() => {
for (const [key, value] of Object.entries(payload.settings)) {
if (RESTORE_SKIP_KEYS.has(key)) continue;
if (typeof value !== 'string') continue;
upsert.run(key, value);
}
// The freshly seeded master url wins over whatever the backup recorded:
// the operator just typed it, and an old backup may point at a dead host.
if (url) upsert.run('master_dashboard_url', url);
if (token) upsert.run('master_backup_token', token);
for (const c of payload.credentials ?? []) {
setCred.run(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '', c.updated_at ?? new Date().toISOString());
}
for (const i of payload.instruments ?? []) {
setInstr.run(i.symbol, i.enabled ? 1 : 0);
}
for (const a of payload.assignments ?? []) {
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
}
})();
console.log(` restored: ${Object.keys(payload.settings).length} settings, ` +
`${(payload.credentials ?? []).length} credentials, ` +
`${(payload.instruments ?? []).length} instruments, ` +
`${(payload.assignments ?? []).length} assignments`);
console.log(` adopted identity: "${payload.settings.instance_name ?? ''}" (${payload.settings.instance_id ?? 'no uuid'})`);
}
restore()
.catch((err) => { console.error(` restore failed: ${err.message}`); process.exit(1); })
.finally(() => db.close());
+31 -28
View File
@@ -1,5 +1,6 @@
/**
* Bring an instance up: clicker dependencies, PM2 processes, then a warm-up.
* Bring the device up: clicker dependencies, PM2 processes, then a warm-up of
* every registered instance.
*
* Run by the "at log on" scheduled task. Safe to run by hand at any time.
*
@@ -9,13 +10,8 @@
* HTTP request the process sits idle and none of that happens.
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ensurePythonDeps } from './ensure-python-deps.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PORT = process.env.AUTOFIRMER_PORT ?? '3000';
const BASE = `http://127.0.0.1:${PORT}`;
import { ROOT, loadInstances } from './instances.mjs';
const log = (msg) => console.log(`[start-all] ${msg}`);
@@ -28,11 +24,11 @@ function run(cmd, args) {
});
}
async function waitForApp(timeoutMs = 120_000) {
async function waitForApp(base, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) });
const res = await fetch(`${base}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) });
if (res.ok) return true;
} catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 2_000));
@@ -50,25 +46,32 @@ if (run('pm2', ['resurrect']).status !== 0) {
process.exit(1);
}
// ── warm-up ─────────────────────────────────────────────────────────────────
log('waiting for the dashboard to answer');
if (!(await waitForApp())) {
log(`dashboard did not come up on ${BASE} within 120s — check \`pm2 logs autofirmer\``);
process.exit(1);
// ── warm-up, one instance at a time ─────────────────────────────────────────
let failed = false;
for (const instance of loadInstances()) {
const base = `http://127.0.0.1:${instance.port}`;
log(`waiting for ${instance.name} to answer on ${base}`);
if (!(await waitForApp(base))) {
log(`${instance.name} did not come up on ${base} within 120s — check \`pm2 logs ${instance.name}\``);
failed = true;
continue;
}
log(`warming up ${instance.name} (this is what triggers the client bootstrap)`);
try {
await fetch(`${base}/api/state`, { signal: AbortSignal.timeout(60_000) });
} catch (err) {
log(`warm-up request failed: ${err.message}`);
}
try {
const status = await (await fetch(`${base}/api/auto-trade`)).json();
log(status.running
? `${instance.name}: scheduler resumed — ${status.action} ${status.symbol}`
: `${instance.name}: scheduler is stopped`);
} catch { /* non-critical */ }
}
log('warming up (this is what triggers the client bootstrap)');
try {
await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) });
} catch (err) {
log(`warm-up request failed: ${err.message}`);
}
try {
const status = await (await fetch(`${BASE}/api/auto-trade`)).json();
log(status.running
? `scheduler resumed — ${status.action} ${status.symbol}`
: 'scheduler is stopped');
} catch { /* non-critical */ }
if (failed) process.exit(1);
log('up');
+31 -19
View File
@@ -16,14 +16,16 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync,
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ensurePythonDeps } from './ensure-python-deps.mjs';
import { loadInstances } from './instances.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOG_FILE = path.join(ROOT, 'scripts', 'update.log');
const LOCK_FILE = path.join(ROOT, 'scripts', '.update.lock');
const LAST_CHECK_FILE = path.join(ROOT, 'scripts', '.last-check');
const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
const PORT = process.env.AUTOFIRMER_PORT ?? '3000';
const BASE = `http://127.0.0.1:${PORT}`;
// One checkout, one build — but possibly several PM2 processes to restart and
// warm afterwards (see scripts/instances.mjs).
const INSTANCES = loadInstances();
const DRY_RUN = process.argv.includes('--dry-run');
const STALE_LOCK_MS = 60 * 60 * 1000;
const LOG_MAX_BYTES = 256 * 1024;
@@ -126,7 +128,7 @@ async function main() {
changed.includes('package-lock.json') && 'npm install',
changed.includes('clicker/requirements.txt') && 'pip install',
'npm run build',
'pm2 restart autofirmer',
...INSTANCES.map((i) => `pm2 restart ${i.name}`),
changed.some((f) => f.startsWith('clicker/')) && 'pm2 restart clicker',
].filter(Boolean).join(', ')}`);
return 0;
@@ -153,8 +155,10 @@ async function main() {
return 1;
}
log('restarting autofirmer');
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); outcome = 'pm2 restart failed'; return 1; }
for (const instance of INSTANCES) {
log(`restarting ${instance.name}`);
if (run('pm2', ['restart', instance.name]).status !== 0) { log(`pm2 restart ${instance.name} failed`); outcome = 'pm2 restart failed'; return 1; }
}
if (changed.some((f) => f.startsWith('clicker/'))) {
log('clicker changed — restarting it too');
@@ -178,21 +182,29 @@ async function main() {
}
async function warmUp() {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
for (const instance of INSTANCES) {
const base = `http://127.0.0.1:${instance.port}`;
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
try {
if ((await fetch(`${base}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) })).ok) break;
} catch { /* still restarting */ }
await new Promise((r) => setTimeout(r, 2_000));
}
try {
if ((await fetch(`${BASE}/api/auto-trade`, { signal: AbortSignal.timeout(5_000) })).ok) break;
} catch { /* still restarting */ }
await new Promise((r) => setTimeout(r, 2_000));
}
try {
await fetch(`${BASE}/api/state`, { signal: AbortSignal.timeout(60_000) });
const status = await (await fetch(`${BASE}/api/auto-trade`)).json();
log(status.running ? `scheduler resumed — ${status.action} ${status.symbol}` : 'scheduler is stopped');
const runner = await (await fetch(`${BASE}/api/autobuyer/runner`)).json();
log(`runner ${runner.online ? 'online' : 'OFFLINE'}${runner.stale ? ' (version stale — reload the extension)' : ''}`);
} catch (err) {
log(`post-restart check failed: ${err.message}`);
await fetch(`${base}/api/state`, { signal: AbortSignal.timeout(60_000) });
const status = await (await fetch(`${base}/api/auto-trade`)).json();
log(status.running
? `${instance.name}: scheduler resumed — ${status.action} ${status.symbol}`
: `${instance.name}: scheduler is stopped`);
// The clicker/extension runner only ever serves instance 1.
if (instance.port === 3000) {
const runner = await (await fetch(`${base}/api/autobuyer/runner`)).json();
log(`runner ${runner.online ? 'online' : 'OFFLINE'}${runner.stale ? ' (version stale — reload the extension)' : ''}`);
}
} catch (err) {
log(`${instance.name}: post-restart check failed: ${err.message}`);
}
}
}