Record a heartbeat so a silent update checker is distinguishable from a dead one

There was no way to tell "running, nothing to pull" from "not running". The
no-change path logs nothing by design, and PM2 reports a cron-restart process
as `stopped` with ↺ 0 even while firing on schedule — I verified that against
PM2 7.0.4: a one-minute cron fired four times without the counter moving once.

scripts/.last-check is now rewritten on every run with the outcome. It is a
single overwritten line, so it needs no trimming.

The outcome is set by each exit path and written once in `finally`, rather than
calling record at each return — the first version of this did the latter and
already missed the build-failure path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 19:43:43 -05:00
co-authored by Claude Opus 5
parent 27a61fd25c
commit 0ba27161ce
4 changed files with 80 additions and 6 deletions
+31
View File
@@ -0,0 +1,31 @@
/**
* Writes the reporter settings into autotrader.sqlite before first launch.
*
* 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().
*
* node scripts/seed-settings.js <master_dashboard_url> <instance_name>
*/
const path = require('path');
const Database = require('better-sqlite3');
const [, , url, name] = process.argv;
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
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); }
db.close();
+31 -4
View File
@@ -20,6 +20,7 @@ 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 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}`;
@@ -50,6 +51,21 @@ function log(msg) {
try { appendFileSync(LOG_FILE, line + '\n'); } catch { /* logging must never throw */ }
}
/**
* Record that a check happened, whether or not it found anything.
*
* Overwritten rather than appended, so it stays one line and needs no trimming.
* Without it there is no way to tell "running, nothing to pull" from "not
* running at all": the no-change path logs nothing by design, and PM2 reports a
* cron-restart process as `stopped` with a restart count of 0 even while it is
* firing on schedule.
*/
function recordCheck(status) {
try {
writeFileSync(LAST_CHECK_FILE, `${new Date().toISOString()} ${status}\n`);
} catch { /* never let bookkeeping break an update */ }
}
function run(cmd, args, { capture = false } = {}) {
return spawnSync(cmd, args, {
cwd: ROOT,
@@ -88,12 +104,17 @@ async function main() {
return 0;
}
let outcome = 'interrupted';
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
if (local === remote) {
outcome = `up to date at ${local.slice(0, 8)}`;
return 0; // silent in the log by design
}
log(`update available: ${local.slice(0, 8)} -> ${remote.slice(0, 8)}`);
const changed = git('diff', '--name-only', local, remote).split('\n').filter(Boolean);
@@ -115,7 +136,7 @@ async function main() {
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 (run('npm', ['install']).status !== 0) { log('ABORTED: npm install failed'); outcome = 'npm install failed'; return 1; }
}
if (changed.includes('clicker/requirements.txt')) {
@@ -128,11 +149,12 @@ async function main() {
log('building');
if (run('npm', ['run', 'build']).status !== 0) {
log('ABORTED: build failed — the previous build is still serving, nothing was restarted');
outcome = 'build failed — not deployed';
return 1;
}
log('restarting autofirmer');
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); return 1; }
if (run('pm2', ['restart', 'autofirmer']).status !== 0) { log('pm2 restart autofirmer failed'); outcome = 'pm2 restart failed'; return 1; }
if (changed.some((f) => f.startsWith('clicker/'))) {
log('clicker changed — restarting it too');
@@ -141,12 +163,17 @@ async function main() {
await warmUp();
log(`updated to ${remote.slice(0, 8)}`);
outcome = `updated to ${remote.slice(0, 8)}`;
return 0;
} catch (err) {
log(`ERROR: ${err.message}`);
outcome = `ERROR: ${err.message}`;
return 1;
} finally {
if (!DRY_RUN) releaseLock();
if (!DRY_RUN) {
releaseLock();
recordCheck(outcome);
}
}
}