← back to Dw Canary Watchdog
ops-digest.mjs
95 lines
#!/usr/bin/env node
// Unified Ops Health Digest (Council #1 fresh, 2026-06-15) — collapse the canary fleet
// into ONE color-coded scorecard + post a single CNCP card. READ-ONLY. Complements the
// watchdog: the watchdog proves canaries RAN; the digest reports what they FOUND.
// Daily email only with --email (per-canary FAIL emails already handle real alerts).
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const HOME = os.homedir();
const SKILLS = path.join(HOME, '.claude/skills');
const SELF = path.join(HOME, 'Projects/dw-canary-watchdog');
const DATA = path.join(SELF, 'data');
fs.mkdirSync(DATA, { recursive: true });
const ENV = process.env;
const EMAIL = process.argv.includes('--email');
const DAILY = process.argv.includes('--daily'); // force the CNCP card even when all-green
const readJSON = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
const ageH = (iso) => { if (!iso) return null; const t = Date.parse(iso); return Number.isFinite(t) ? +((Date.now() - t) / 3.6e6).toFixed(1) : null; };
const dot = (s) => s === 'FAIL' ? '🔴' : s === 'WARN' ? '🟡' : s === 'OK' ? '🟢' : '⚪';
// per-canary adapters: file + how to read status/headline from its latest.json shape
const CANARIES = [
{ label: 'uptime-probe', file: `${SKILLS}/dw-uptime-probe/data/latest.json`, cadence: '15min',
adapt: d => ({ status: d.down ? 'FAIL' : d.degraded ? 'WARN' : 'OK', head: `${d.up||0}↑ ${d.degraded||0}~ ${d.down||0}↓ of ${d.sites??'?'} sites`, at: d.scanned_at }) },
{ label: 'scraper-canary', file: `${SKILLS}/dw-scraper-canary/data/latest.json`, cadence: 'daily',
adapt: d => ({ status: d.fail ? 'FAIL' : d.warn ? 'WARN' : 'OK', head: `${d.fail||0} fail / ${d.warn||0} warn of ${d.vendors??'?'} vendors${d.unknown?` (${d.unknown} unknown)`:''}`, at: d.scanned_at }) },
{ label: 'map-auditor', file: `${SKILLS}/dw-map-auditor/data/latest.json`, cadence: 'daily',
adapt: d => ({ status: d.verdict === 'FAIL' || d.fail ? 'FAIL' : d.warn ? 'WARN' : 'OK', head: `${d.verdict||'?'} · ${d.fail||0} fail / ${d.warn||0} warn`, at: d.scanned_at }) },
{ label: 'backup-canary', file: `${SKILLS}/dw-backup-canary/data/latest.json`, cadence: 'weekly',
adapt: d => ({ status: d.fail ? 'FAIL' : d.warn ? 'WARN' : 'OK', head: `${d.fail||0} fail / ${d.warn||0} warn · ${d.repos??'?'} repos (${d.no_remote||0} no-remote ok)`, at: d.scanned_at }) },
{ label: 'leak-scanner', file: `${SKILLS}/dw-leak-scanner/data/latest.json`, cadence: 'weekly',
adapt: d => ({ status: (d.leaking?.length ?? d.leaking ?? 0) ? 'FAIL' : 'OK', head: `${(Array.isArray(d.leaking)?d.leaking.length:d.leaking)||0} leaking / ${(Array.isArray(d.clean)?d.clean.length:d.clean)??'?'} clean of ${d.targets??'?'}`, at: d.scanned_at }) },
];
const rows = [];
for (const c of CANARIES) {
const d = readJSON(c.file);
if (!d) { rows.push({ label: c.label, status: 'STALE', head: 'no latest.json', cadence: c.cadence, age_h: null }); continue; }
const a = c.adapt(d);
rows.push({ label: c.label, status: a.status, head: a.head, cadence: c.cadence, age_h: ageH(a.at) });
}
// scraper-healthcheck (text log, no JSON) — presence/age only
try { const st = fs.statSync('/tmp/scraper-healthcheck.log'); const txt = fs.readFileSync('/tmp/scraper-healthcheck.log', 'utf8'); const bad = /REGRESSION|✗|FAIL/i.test(txt.slice(-2000));
rows.push({ label: 'scraper-healthcheck', status: bad ? 'WARN' : 'OK', head: bad ? 'regressions in last run' : 'no regressions', cadence: 'daily', age_h: +((Date.now() - st.mtimeMs) / 3.6e6).toFixed(1) }); } catch {}
// watchdog self
const wd = readJSON(`${DATA}/latest.json`);
if (wd) rows.push({ label: 'canary-watchdog', status: wd.fail ? 'FAIL' : wd.warn ? 'WARN' : 'OK', head: `${wd.ok}/${wd.total} canaries running${wd.fail?` · ${wd.fail} dark`:''}`, cadence: 'hourly', age_h: ageH(wd.ts) });
const nFail = rows.filter(r => r.status === 'FAIL').length, nWarn = rows.filter(r => r.status === 'WARN' || r.status === 'STALE').length;
const overall = nFail ? '🔴 ATTENTION' : nWarn ? '🟡 WATCH' : '🟢 ALL GREEN';
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
const lines = rows.map(r => `${dot(r.status)} ${r.label.padEnd(20)} ${r.head}${r.age_h != null ? ` · ${r.age_h}h ago` : ''}`);
const scorecard = `DW Ops Health — ${overall} — ${stamp} PT\n` + lines.join('\n');
fs.writeFileSync(`${DATA}/digest-latest.json`, JSON.stringify({ ts: new Date().toISOString(), overall, fail: nFail, warn: nWarn, rows }, null, 2));
fs.writeFileSync(`${DATA}/digest.md`, '```\n' + scorecard + '\n```\n');
console.log(scorecard);
async function postCNCP(note) {
const base = ENV.CNCP_URL || 'http://localhost:3333';
try { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 20000);
const r = await fetch(`${base}/api/parking-lot`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'file://' + SELF, note }), signal: ac.signal });
clearTimeout(t); return r.ok; } catch { return false; }
}
async function emailGeorge(subject, html) {
let auth = ENV.GEORGE_AUTH;
if (!auth) { try { auth = (fs.readFileSync(path.join(HOME, 'Projects/secrets-manager/.env'), 'utf8').match(/^GEORGE_AUTH=(.*)$/m) || [])[1]; } catch {} }
if (!auth) return false;
const host = ENV.GEORGE_HOST || '100.107.67.67', port = ENV.GEORGE_PORT || '9850';
try { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 15000);
const r = await fetch(`http://${host}:${port}/api/send`, { method: 'POST', headers: { 'Authorization': auth, 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ to: ENV.OPS_DIGEST_TO || 'steve@designerwallcoverings.com', subject, body: html }), signal: ac.signal });
clearTimeout(t); const j = await r.json().catch(() => ({})); return !!j.success; } catch { return false; }
}
// Post to CNCP only when the health STATE CHANGES (a canary's status flipped) or once-daily
// (--daily). Persistent known FAILs must NOT re-post every tick — that's the noise we avoid.
const sigFile = `${DATA}/.last-post-sig`;
const sig = `${overall}|` + rows.filter(r => r.status !== 'OK').map(r => `${r.label}:${r.status}`).sort().join(',');
let lastSig = ''; try { lastSig = fs.readFileSync(sigFile, 'utf8').trim(); } catch {}
if (sig !== lastSig || DAILY) {
const posted = await postCNCP(`[OPS HEALTH ${stamp.slice(0, 10)}] ${overall} — ${rows.map(r => dot(r.status) + r.label.replace(/-canary|-probe|-scanner|-healthcheck/, '')).join(' ')}`);
if (posted) { try { fs.writeFileSync(sigFile, sig); } catch {} }
console.log(posted ? `CNCP scorecard posted (state ${sig === lastSig ? 'forced --daily' : 'changed'})` : 'CNCP post failed (card may still have landed — server is slow)');
} else {
console.log(`state unchanged since last post — CNCP skipped (sig: ${sig.slice(0, 60)})`);
}
if (EMAIL) {
const html = `<div style="font-family:-apple-system,monospace"><h3>DW Ops Health — ${overall}</h3><pre style="font-size:13px;line-height:1.5">${lines.join('\n')}</pre><div style="color:#888;font-size:11px">${stamp} PT · ${nFail} fail / ${nWarn} watch</div></div>`;
const sent = await emailGeorge(`DW Ops Health — ${overall} — ${stamp.slice(0, 10)}`, html);
console.log(sent ? 'digest emailed' : 'digest email not sent');
}
process.exitCode = nFail ? 1 : 0;