Instances were started by hand and updated by hand, so they drifted behind master silently. Now: PM2 supervises the dashboard and the clicker, a logon task brings them up, and a 5-minute task pulls, rebuilds and restarts when master moves. Restarting on every push is only safe because the scheduler now survives it. It was pure in-memory state (_global.__autoTrader), so any restart silently stopped automated trading with the dashboard simply showing it as off. It now mirrors running/action/symbol/stopAfterAll to the settings table, and resumeSchedulerIfPersisted() picks it back up from the getClients() bootstrap. No sync-wait was needed there: tick() already skips while a client reports !syncComplete and while any account holds a position. A failed build is never deployed — the build runs before anything restarts, so a broken push leaves the previous build serving. start-all and update-check both warm the app with a request afterwards. That is load-bearing: getClients() is lazily bootstrapped, so until something makes an HTTP request the Tradovate clients, the reporter and the resumed schedule never start. That was already true of manual restarts. Logic lives in Node so a macOS or Linux port only needs an equivalent of install-autostart.ps1. Python deps are hash-guarded, so the common path is one hash and one import with no network, and failure is non-fatal since only the clicker needs them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
6.1 KiB
JavaScript
154 lines
6.1 KiB
JavaScript
/**
|
|
* Pull master, rebuild, restart — once per invocation.
|
|
*
|
|
* A scheduled task fires this every few minutes. Deliberately not a long-lived
|
|
* loop: if a run dies, the next fire is a clean slate.
|
|
*
|
|
* The important guarantee is that a broken push cannot take a trading PC down.
|
|
* The build runs before anything is restarted, and a failed build stops the run
|
|
* with the previous build still serving.
|
|
*
|
|
* node scripts/update-check.mjs # normal
|
|
* node scripts/update-check.mjs --dry-run # report only, change nothing
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
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 LOG_FILE = path.join(ROOT, 'scripts', 'update.log');
|
|
const LOCK_FILE = path.join(ROOT, 'scripts', '.update.lock');
|
|
const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
|
|
const PORT = process.env.AUTOFIRMER_PORT ?? '3000';
|
|
const BASE = `http://127.0.0.1:${PORT}`;
|
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
const STALE_LOCK_MS = 60 * 60 * 1000;
|
|
|
|
function log(msg) {
|
|
const line = `${new Date().toISOString()} ${msg}`;
|
|
console.log(line);
|
|
try { appendFileSync(LOG_FILE, line + '\n'); } catch { /* logging must never throw */ }
|
|
}
|
|
|
|
function run(cmd, args, { capture = false } = {}) {
|
|
return spawnSync(cmd, args, {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
stdio: capture ? 'pipe' : 'inherit',
|
|
shell: process.platform === 'win32',
|
|
});
|
|
}
|
|
|
|
function git(...args) {
|
|
const r = run('git', args, { capture: true });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr ?? '').trim()}`);
|
|
return (r.stdout ?? '').trim();
|
|
}
|
|
|
|
// ── lock ────────────────────────────────────────────────────────────────────
|
|
// A build outlasts the schedule interval, so overlapping runs are otherwise a
|
|
// certainty rather than a risk.
|
|
function acquireLock() {
|
|
if (existsSync(LOCK_FILE)) {
|
|
const age = Date.now() - Number(readFileSync(LOCK_FILE, 'utf8').trim() || 0);
|
|
if (age < STALE_LOCK_MS) return false;
|
|
log(`clearing a stale lock (${Math.round(age / 60000)} min old)`);
|
|
}
|
|
writeFileSync(LOCK_FILE, String(Date.now()));
|
|
return true;
|
|
}
|
|
const releaseLock = () => { try { rmSync(LOCK_FILE, { force: true }); } catch { /* ignore */ } };
|
|
|
|
async function main() {
|
|
mkdirSync(path.join(ROOT, 'scripts'), { recursive: true });
|
|
|
|
if (!DRY_RUN && !acquireLock()) {
|
|
console.log('another update is already running — exiting');
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
git('fetch', 'origin', BRANCH);
|
|
const local = git('rev-parse', 'HEAD');
|
|
const remote = git('rev-parse', `origin/${BRANCH}`);
|
|
|
|
if (local === remote) return 0; // the common path: silent no-op
|
|
|
|
log(`update available: ${local.slice(0, 8)} -> ${remote.slice(0, 8)}`);
|
|
const changed = git('diff', '--name-only', local, remote).split('\n').filter(Boolean);
|
|
log(`${changed.length} file(s) changed`);
|
|
|
|
if (DRY_RUN) {
|
|
log('dry run — stopping before any change');
|
|
log(`would run: ${[
|
|
changed.includes('package-lock.json') && 'npm install',
|
|
changed.includes('clicker/requirements.txt') && 'pip install',
|
|
'npm run build',
|
|
'pm2 restart autofirmer',
|
|
changed.some((f) => f.startsWith('clicker/')) && 'pm2 restart clicker',
|
|
].filter(Boolean).join(', ')}`);
|
|
return 0;
|
|
}
|
|
|
|
git('pull', '--ff-only', 'origin', BRANCH);
|
|
|
|
if (changed.includes('package-lock.json')) {
|
|
log('package-lock.json changed — npm install');
|
|
if (run('npm', ['install']).status !== 0) { log('ABORTED: npm install failed'); return 1; }
|
|
}
|
|
|
|
if (changed.includes('clicker/requirements.txt')) {
|
|
log('clicker/requirements.txt changed — refreshing python deps');
|
|
ensurePythonDeps({ log: (m) => log(m) }); // non-fatal by design
|
|
}
|
|
|
|
// Build BEFORE restarting. A failed build leaves the running process
|
|
// untouched, which is the whole point of doing it in this order.
|
|
log('building');
|
|
if (run('npm', ['run', 'build']).status !== 0) {
|
|
log('ABORTED: build failed — the previous build is still serving, nothing was restarted');
|
|
return 1;
|
|
}
|
|
|
|
log('restarting autofirmer');
|
|
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); return 1; }
|
|
|
|
if (changed.some((f) => f.startsWith('clicker/'))) {
|
|
log('clicker changed — restarting it too');
|
|
run('pm2', ['restart', 'clicker']);
|
|
}
|
|
|
|
await warmUp();
|
|
log(`updated to ${remote.slice(0, 8)}`);
|
|
return 0;
|
|
} catch (err) {
|
|
log(`ERROR: ${err.message}`);
|
|
return 1;
|
|
} finally {
|
|
if (!DRY_RUN) releaseLock();
|
|
}
|
|
}
|
|
|
|
async function warmUp() {
|
|
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 {
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
process.exit(await main());
|