[object Object]

← back to Dw Canary Watchdog

Add ops-digest: unified canary health scorecard + CNCP card (Council #1 fresh)

347a2b569adfad359967dd592101f39f3b5ec708 · 2026-06-15 23:16:25 -0700 · Steve Abrams

Files touched

Diff

commit 347a2b569adfad359967dd592101f39f3b5ec708
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 15 23:16:25 2026 -0700

    Add ops-digest: unified canary health scorecard + CNCP card (Council #1 fresh)
---
 README.md      | 17 +++++++++++
 data/digest.md | 10 +++++++
 ops-digest.mjs | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 117 insertions(+)

diff --git a/README.md b/README.md
index 5ef32a6..b3a6393 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,23 @@ Scheduled hourly via `com.steve.dw-canary-watchdog` (see `com.steve.dw-canary-wa
 `CNCP_URL` (default http://localhost:3333) · `GEORGE_AUTH/GEORGE_HOST/GEORGE_PORT`
 · `WATCHDOG_TO` (default steve@designerwallcoverings.com) · `WATCHDOG_BEAT=0`.
 
+## Sibling: `ops-digest.mjs` — unified ops health scorecard (Council #1 fresh, 2026-06-15)
+The watchdog proves canaries *ran*; the digest reports what they *found* — one
+color-coded scorecard across the whole fleet (uptime / scraper / map-auditor / backup /
+leak / scraper-healthcheck + the watchdog itself), 🟢/🟡/🔴 per surface.
+```bash
+node ~/Projects/dw-canary-watchdog/ops-digest.mjs            # write scorecard; CNCP post only if non-green
+node ~/Projects/dw-canary-watchdog/ops-digest.mjs --daily    # force the CNCP card even when all-green
+node ~/Projects/dw-canary-watchdog/ops-digest.mjs --email    # also email the digest via George
+```
+- Always writes `data/digest-latest.json` + `data/digest.md` (the scorecard).
+- **Posts to CNCP parking-lot only when non-green or `--daily`** — an hourly run won't spam
+  24 green cards/day. Suggested schedule: hourly bare (alerts), plus one `--daily --email`
+  at 8am for the morning summary.
+- Per-canary adapters read each canary's `data/latest.json` (differing shapes).
+- *Follow-up (council first-step):* a dedicated CNCP `/api/ops-status` **panel** that shows
+  ONE live updating scorecard instead of parking-lot cards — touches cncp-starter, deferred.
+
 ## Hard rules
 - READ-ONLY on the canaries. Alerts only. Never edits/disables a canary.
 - Exit 0 = all running; exit 1 = ≥1 FAIL.
diff --git a/data/digest.md b/data/digest.md
new file mode 100644
index 0000000..ce0b834
--- /dev/null
+++ b/data/digest.md
@@ -0,0 +1,10 @@
+```
+DW Ops Health — 🔴 ATTENTION — 2026-06-16 06:15 PT
+🟢 uptime-probe         13↑ 0~ 0↓ of 20 sites  ·  0.2h ago
+🔴 scraper-canary       1 fail / 0 warn of 10 vendors (1 unknown)  ·  0.5h ago
+🟢 map-auditor          PASS · 0 fail / 0 warn  ·  0.5h ago
+🔴 backup-canary        1 fail / 6 warn · 324 repos (318 no-remote ok)  ·  0.5h ago
+🟢 leak-scanner         0 leaking / 6 clean of 6  ·  0.5h ago
+🟡 scraper-healthcheck  regressions in last run  ·  20.8h ago
+🟢 canary-watchdog      6/6 canaries running  ·  0.3h ago
+```
diff --git a/ops-digest.mjs b/ops-digest.mjs
new file mode 100644
index 0000000..c45c9d9
--- /dev/null
+++ b/ops-digest.mjs
@@ -0,0 +1,90 @@
+#!/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 there's something to see (non-green) or once-daily (--daily),
+// so an hourly run doesn't append 24 green cards/day to the parking-lot.
+if (nFail || nWarn || 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(' ')}`);
+  console.log(posted ? 'CNCP scorecard posted' : 'CNCP post failed (card may still have landed — server is slow)');
+} else {
+  console.log('all-green — scorecard written to data/digest.md, CNCP post skipped (use --daily to force)');
+}
+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;

← 0fb010f Canary Meta-Watchdog: prove the DW canaries actually ran (Co  ·  back to Dw Canary Watchdog  ·  ops-digest: CNCP post only on health state-change (no per-ti bd93b05 →