← back to Doing Viewer
publish-snapshot.js
124 lines
#!/usr/bin/env node
// publish-snapshot.js (runs on Mac2 — the source of truth)
// Bakes a COMPLETE snapshot of the doing board (tickets + already-computed
// plain-word summaries) from the live local server, then rsyncs just that one
// small JSON up to Kamatera. Kamatera serves it verbatim — no ticket log, no
// Ollama needed there. Run every few minutes via launchd.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const LOCAL = 'http://127.0.0.1:9790/api/doing';
const USER = process.env.BASIC_USER || 'admin';
const PASS = process.env.BASIC_PASS || 'DW2024!';
const SNAP = path.join(__dirname, 'data', 'snapshot.json');
const REMOTE = 'root@45.61.58.125:/root/Projects/doing-viewer/data/snapshot.json';
// Connection-level errors that mean the SOURCE server (:9790) is simply not up
// right now — a self-healing condition (keep-alive + dw-uptime-probe watch that
// process; a LONG outage makes the Kamatera snapshot go stale, which
// cron-fire-canary catches via artifact-freshness). These must NOT be conflated
// with a real publish failure, so we tag them and exit 0 on this tick instead of
// flapping the canary with exit=1 (TK-11446).
const SOURCE_DOWN_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EHOSTUNREACH']);
function getLocal() {
return new Promise((resolve, reject) => {
const auth = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
// 30s (was 20s): the local server computes plain-word summaries for ~56 doing
// tickets on the fly, which under load occasionally crept past 20s and threw
// 'timeout', crashing an otherwise-healthy run (TK-11446 flapping).
http.get(LOCAL, { headers: { Authorization: auth }, timeout: 30000 }, (res) => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
}).on('error', (err) => {
// Mark "source process is down" distinctly from "source up but publish broke".
if (err && SOURCE_DOWN_CODES.has(err.code)) err.sourceDown = true;
reject(err);
}).on('timeout', function () { this.destroy(); reject(new Error('timeout')); });
// NOTE: a 30s timeout is NOT treated as sourceDown — it means the server is
// reachable but the summarizer stalled, a genuine issue worth an exit=1 warn.
});
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Track how long the source has been continuously unreachable, ACROSS launchd
// runs (this script runs once per 180s fire — no in-memory state survives).
// A brief blip (e.g. a pm2 restart) self-heals and should NOT flap the canary,
// but a SUSTAINED outage must stay visible: cron-fire-canary flags this job as
// artifact_weak (its publish.log refreshes every tick regardless of outcome),
// so the launchd EXIT CODE is the only signal it can trust here. We therefore
// exit 0 only while the outage is inside the self-heal grace window, and exit 1
// once it is sustained — so a real, prolonged source-down is genuinely reported
// (TK-11431: a failed input must not be silently reported as success).
const DOWN_STATE = path.join(__dirname, 'data', 'source-down-since.json');
const SOURCE_DOWN_GRACE_MS = 10 * 60 * 1000; // ~3-4 ticks: tolerate a transient restart
function clearDownState() { try { fs.unlinkSync(DOWN_STATE); } catch (_) {} }
function recordDownAndAgeMs() {
let since = null;
try { since = JSON.parse(fs.readFileSync(DOWN_STATE, 'utf8')).since; } catch (_) {}
if (!since) { since = Date.now(); try { fs.writeFileSync(DOWN_STATE, JSON.stringify({ since })); } catch (_) {} }
return Date.now() - since;
}
async function publishOnce() {
const data = await getLocal();
const snap = {
syncedAt: new Date().toISOString(),
source: 'mac2',
count: data.count,
items: data.items.map(i => ({ // strip nothing — summaries baked in
id: i.id, title: i.title, project: i.project,
agent: i.agent, doingText: i.doingText, doingTs: i.doingTs,
ageH: i.ageH, eli12: i.eli12 || null, diag: i.diag || null,
})),
};
fs.writeFileSync(SNAP, JSON.stringify(snap));
// rsync the single file (atomic on the remote via --inplace off / temp+rename default)
execFileSync('rsync', ['-az', '--timeout=25', SNAP, REMOTE], { stdio: 'inherit' });
const summarized = snap.items.filter(i => i.eli12).length;
console.log(`[${snap.syncedAt}] published ${snap.count} doing (${summarized} summarized) -> Kamatera`);
}
// Retry ONCE on a transient failure (slow local fetch or a slow Kamatera rsync)
// before deciding the exit code. A single blip no longer crash-flags the job.
// Exit-code semantics (TK-11446):
// - SOURCE server (:9790) is simply down after a retry -> exit 0 + WARN.
// This is self-healing and monitored elsewhere (keep-alive restarts the pm2
// process; a LONG outage staleness is caught by cron-fire-canary's
// artifact-freshness check). It is NOT a failure of THIS cron job's logic,
// so we don't flap the canary with a false exit=1 for a dependency outage.
// - Any OTHER sustained failure (summarizer timeout, malformed JSON = source
// up but broken, rsync-to-Kamatera failure) -> exit 1, so the canary
// genuinely warns on a real publish-path problem.
(async () => {
try {
await publishOnce();
clearDownState(); // source reachable + published -> outage (if any) is over
} catch (e1) {
console.error(`publish attempt 1 failed: ${e1.message} — retrying once in 5s`);
await sleep(5000);
try {
await publishOnce();
clearDownState();
} catch (e2) {
if (e2 && e2.sourceDown) {
const downMs = recordDownAndAgeMs();
const downMin = Math.round(downMs / 60000);
if (downMs >= SOURCE_DOWN_GRACE_MS) {
console.error(`source :9790 unreachable (${e2.code}) for ~${downMin}m (>= ${SOURCE_DOWN_GRACE_MS / 60000}m grace) — SUSTAINED outage, exit 1 so cron-fire-canary warns.`);
process.exit(1);
}
console.error(`source :9790 unreachable (${e2.code}) for ~${downMin}m — within self-heal grace, skipping this tick (exit 0). keep-alive should restore :9790.`);
process.exit(0);
}
console.error(`publish failed (after retry): ${e2.message}`);
process.exit(1);
}
}
})();