diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 8844ca1..2c9aff3 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -84,6 +84,34 @@ work-order 1.6 closed (canonical player keys + slate join invariant), Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET; first snapshot locks against freshly posted lines (manual internal trigger). +## Session 8 (A1 board, 2026-07-11) — SHIPPED ✅ OPS: THE PRODUCT WATCHES ITSELF + +Branch off day1/a1-board tip. 2398 → 2437 tests (208 suites). Backend-only. +- **Settlement alarm** (`snapshotScheduler`): outcome/ledger settle pass + THROW → ntfy high. Morning zero-settle alarm: ledger settle found + pre-today Postgres rows but settled none → page once per ET date + (signal = settleLedger's own return values — survives the Redis-TTL + failure mode that silently killed morning settles in S60; documented in + `opsWatch.zeroSettleAlarm`). Genuinely empty yesterday never alerts. +- **Persistent-failure pager** (`opsWatch.createFailureTracker`, pure): + 3 consecutive error / skipped-'no props' slots for a sport pages once + (high); any good slot resets and re-arms. Single-slot misses stay quiet. +- **Quota** (`opsWatch.checkQuotaDaily`): odds-api ≥80% after a snapshot + run → one alert/day, Redis-deduped (`ops:quota_day:{provider}:{date}`). +- **Box health** (`services/systemHealth`, pure + injectable): fs.statfs + + os mem → `{disk_pct, mem_pct}`; pages high at disk>85 / mem>90. +- **Daily pulse** 13:00 UTC (9 AM EDT; `PULSE_HOUR_UTC` to move), ONE + notification: ledger rows yesterday (`ledgerService.countRowsForDate`, + 'n/a' when Supabase off — never a fabricated 0), settles last 24h, + quota pct, disk/mem, 'desk pack: see /desk'. Dedupe: in-process date + + `ops:pulse:{date}`. +- **docs/OPS-RUNBOOK.md**: Uptime Kuma monitors (vyndr.app 200, api + /api/health keyword 'healthy', /api/snapshot/summary), Coolify + deploy-failure webhook → ntfy, ntfy phone-subscription steps. +- Live acceptance alert POSTed to ntfy.sh/vyndr-pipeline-kev2026. +- All alert copy VOICE v1.1 — deadpan, numbers, zero exclamation points + (tests lint for `!`). Spec: `specs/session-8-ops-watch.md`. + ## Session 60 (night2/full-board, 2026-07-11) — BUILT ✅ THE WHOLE BOARD (awaiting "merge the train") Branch `night2/full-board` off d10bb4c — ZERO pushes to main. 2352 → diff --git a/CLAUDE.md b/CLAUDE.md index e891764..f0e4371 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -854,6 +854,31 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). surface is `/welcome`. Landing capture sits below FAQ. Subscribe route is 10/min IP-limited → keep `tests/integration/newsletterRoute.test.js` under 10 subscribe requests (same lesson as analyze.test.js). +## Ops Self-Watch (Session 8, A1 board — non-obvious) +- **Pure alarm logic lives in `src/services/opsWatch.js`** (failure tracker, + zero-settle signal, quota daily check, pulse assembly) + `src/services/ + systemHealth.js` (statfs/os, injectable). `snapshotScheduler` only WIRES + them — keep new alarm logic in opsWatch so it stays unit-testable. +- **Zero-settle signal = the ledger settle's own return values** (Postgres + `game_date < today AND outcome IS NULL`), NOT `snapshot:{sport}:previous` — + the snapshot key expires in exactly the failure mode the alarm exists to + catch (the S60 SNAP_TTL bug). Scoped to `SETTLEABLE_SPORTS` (mlb); + NBA/WNBA rows legitimately stay pending and must not page daily. Morning + slot only (`morningHourUtc` = first configured hour ≥ 06 UTC). +- **Failure pager pages ONCE per losing streak** (exactly at count 3), not + once per failing slot; `status:'skipped', reason:'no grades'` is NOT a + failure (props arrived, grader refused) and resets the counter. +- **Daily pulse** fires at `PULSE_HOUR_UTC` (default 13 = 9 AM EDT), one + notification, dual dedupe (in-process date + Redis `ops:pulse:{date}`). + Missing data renders 'n/a' — a pulse never fabricates a zero (Data + Semantics Rule applies to ops copy). All alert copy: no exclamation + points, ever (VOICE v1.1) — tests lint for it. +- **Single-suite jest runs of scheduler tests can hang at exit** when redis + is down (ioredis reconnect timer keeps the process alive) — PRE-EXISTING + at baseline, full-suite runs are unaffected. Don't chase it as a leak in + new code; inject cacheGet/cacheSet in tests to avoid creating a client. +- Box-side ops (Uptime Kuma, Coolify deploy-failure webhook, ntfy phone + setup) = `docs/OPS-RUNBOOK.md`. ## Active Skills - vyndr-voice (all user-facing output) diff --git a/docs/OPS-RUNBOOK.md b/docs/OPS-RUNBOOK.md new file mode 100644 index 0000000..36a8977 --- /dev/null +++ b/docs/OPS-RUNBOOK.md @@ -0,0 +1,106 @@ +# VYNDR OPS RUNBOOK — the product watches itself (Session 8, A1 board) + +The in-app half (settle alarms, failure pager, quota check, box health, daily +pulse) lives in `src/snapshotScheduler.js` + `src/services/opsWatch.js` + +`src/services/systemHealth.js` and pushes to ntfy via `src/utils/opsNotify.js`. +This runbook is the BOX-SIDE half: the monitors and hooks that catch the cases +the app cannot report on itself — a dead container, a failed deploy, a dead box. + +Channel: `https://ntfy.sh/vyndr-pipeline-kev2026` +(override with `NTFY_URL` / `NTFY_TOPIC`; both default in `opsNotify.js`). +Zero out-of-pocket: ntfy.sh is free, Uptime Kuma is already on the box. + +--- + +## 1. Phone subscription (do this first — alerts nobody reads are logs) + +Android / iOS: +1. Install **ntfy** (Play Store, App Store, or F-Droid). +2. Open the app → **+ Subscribe to topic**. +3. Server: `https://ntfy.sh` (default). Topic: `vyndr-pipeline-kev2026`. +4. Android: in the subscription's settings enable **Instant delivery** + (foreground service) and exempt ntfy from battery optimization + (Settings → Apps → ntfy → Battery → Unrestricted). Without this, + Android can delay high-priority pages by hours. +5. Send yourself a probe and confirm the phone buzzes: + `curl -s -d "probe" https://ntfy.sh/vyndr-pipeline-kev2026` + +Desktop (optional): open `https://ntfy.sh/vyndr-pipeline-kev2026` in a browser +tab and allow notifications. + +Note: the topic name is the only secret. Anyone who knows it can read and +write the channel. If it ever leaks, rotate via `NTFY_TOPIC` on the container +and re-subscribe the phone. + +## 2. Uptime Kuma monitors (external heartbeat — catches a dead container) + +Uptime Kuma already runs on the box. Add THREE monitors +(**Add New Monitor** for each): + +| # | Name | Type | URL | Pass condition | Interval | +|---|------|------|-----|----------------|----------| +| 1 | `vyndr web` | HTTP(s) | `https://vyndr.app` | status 200 | 60s, retries 3 | +| 2 | `vyndr api health` | HTTP(s) — **Keyword** | `https://api.vyndr.app/api/health` | keyword `healthy` present | 60s, retries 3 | +| 3 | `vyndr snapshot summary` | HTTP(s) | `https://api.vyndr.app/api/snapshot/summary` | status 200 | 300s, retries 3 | + +Why keyword on #2: `/api/health` answers 200 `"status":"healthy"` only when +Redis AND Supabase check out; degraded mode returns 503 `"degraded"`. The +keyword check fails on BOTH a dead container and a degraded one. +Monitor #3 is the public cache-only snapshot read — it proves the pipeline's +output surface is serving, and it can never drain provider quota. + +Wire Kuma to the pager: +1. **Settings → Notifications → Setup Notification → ntfy**. +2. Server URL `https://ntfy.sh`, topic `vyndr-pipeline-kev2026`, + priority: default 5 (Kuma sends its own priority on down events). +3. Check **Default enabled** so future monitors inherit it, then attach the + notification to all three monitors above. + +## 3. Coolify deploy-failure -> ntfy + +Coolify owns deploys, so the app can't report its own failed deploy. Two +options, in order of preference: + +**A. Native notification channel (Coolify v4):** +1. Coolify → **Notifications** (team level). +2. If your Coolify version lists **ntfy**: server `https://ntfy.sh`, topic + `vyndr-pipeline-kev2026`, enable only the **Deployment Failed** (and + optionally **Deployment Success**) events. +3. If it doesn't list ntfy, use the generic **Webhook** channel pointed at + `https://ntfy.sh/vyndr-pipeline-kev2026`. ntfy accepts any POST body as + the message text — a JSON payload arrives readable, just ugly. + +**B. Per-app webhook (any Coolify version):** +1. App → **Webhooks** → add `https://ntfy.sh/vyndr-pipeline-kev2026` for the + deployment-failed event. +2. Verify by triggering a deploy of a branch that fails its build; the phone + should receive the payload within seconds. + +Either way, send one manual probe to prove the path before trusting it: +`curl -s -d "coolify webhook path test" https://ntfy.sh/vyndr-pipeline-kev2026` + +## 4. What the app already pages on its own (for reference — do not duplicate) + +| Alert | Source | Priority | +|-------|--------|----------| +| Snapshot success / 0-props / hard failure | `snapshotService` (S56) | default / low / high | +| Missed cron slot (per-minute watchdog) | `snapshotScheduler` (S56) | high | +| Settlement pass THREW (outcomes or ledger) | `snapshotScheduler` (S8) | high | +| Morning settle closed with 0 settles while Postgres held pre-today pending rows | `snapshotScheduler` + `opsWatch.zeroSettleAlarm` (S8) | high | +| Sport produced nothing 3 consecutive slots | `opsWatch.createFailureTracker` (S8) | high | +| odds-api >= 80% quota (once per day) | `opsWatch.checkQuotaDaily` (S8) | high | +| Disk > 85% or memory > 90% | `systemHealth` via daily pulse (S8) | high | +| Daily pulse, 9 AM ET (13:00 UTC, `PULSE_HOUR_UTC` to move) | `snapshotScheduler` (S8) | default | + +Kill switches: `PIPELINE_ALERTS=0` silences everything app-side; +`NODE_ENV=test` is silent by design. + +## 5. Weekly 5-minute sanity pass + +1. Phone got yesterday's 9 AM pulse. If not: check `ops:pulse:*` in Redis and + the container log for `[opsWatch] armed`. +2. `GET /api/internal/snapshot/status` (header `x-internal-key`) shows + `cron_armed: true`, fresh `last_snapshot`, `overdue: false`. +3. Uptime Kuma dashboard: three greens. +4. Disk line in the pulse trending up week over week means log rotation or + Redis growth needs a look before the 85% page ever fires. diff --git a/specs/session-8-ops-watch.md b/specs/session-8-ops-watch.md new file mode 100644 index 0000000..1e5a166 --- /dev/null +++ b/specs/session-8-ops-watch.md @@ -0,0 +1,83 @@ +# Session 8 (A1 board) — Ops: the product watches itself + +## Problem +The pipeline alerts on snapshot success/failure and missed cron slots (S56), +but the record can still die silently: a settlement pass that throws or +quietly settles nothing, a sport erroring slot after slot, quota creeping to +the block threshold, a full disk. Kev must be paged by the product, not +discover it days later. + +## Scope (extends existing scheduler/services — nothing rebuilt) +1. **Settlement alarm** (`snapshotScheduler.js`) + - Settle pass (outcomes or ledger) THROWS -> ntfy priority `high`. + - Zero-settle alarm: at the MORNING slot only (first configured hour + >= 06 UTC; 14 UTC default = 10 AM ET, the first slot after overnight + completions), alert when the ledger settle pass found rows from before + today but settled none. **Chosen signal (documented): the ledger settle + pass's own return values** (`settleLedger` fetches + `game_date < today AND outcome IS NULL` from Postgres). It is the + cheapest (zero extra reads) and the most reliable: Postgres survives the + Redis TTL expiry that silently killed morning settles in Session 60 — + a `snapshot:{sport}:previous` signal would vanish in exactly that + failure mode. Scoped to SETTLEABLE sports (mlb) because NBA/WNBA/soccer + rows legitimately stay pending until their settle feed exists. + A genuinely empty yesterday fetches 0 rows -> no alarm. Deduped once + per ET date via Redis `ops:settle_zero:{date}`. +2. **Persistent snapshot failure** (`opsWatch.createFailureTracker`, pure) + - Per-sport consecutive count of bad outcomes (`status==='error'` or + `skipped` with reason `'no props'`) across cron slots (in-process Map). + - Pages ONCE (priority high) when a sport reaches 3 consecutive bad + slots; stays quiet at 4+; any good outcome resets and re-arms. +3. **Quota alert** (`opsWatch.checkQuotaDaily`) + - After each snapshot run: odds-api usage >= 80% -> one alert per day + (Redis dedupe `ops:quota_day:{provider}:{YYYY-MM-DD}`). Complements the + once-per-MONTH quotaTracker warn. +4. **Box health** (`src/services/systemHealth.js`, pure + injectable) + - `getSystemHealth({fsImpl, osImpl})` -> `{disk_pct, mem_pct}` via + `fs.promises.statfs('/')` + `os.freemem/totalmem`. + - `healthIssues()` flags disk > 85% / mem > 90%; checked in the daily + pulse; issues alert at priority high. +5. **Daily pulse** (scheduler, 13:00 UTC = 9 AM EDT, dedupe by date + in-process + Redis `ops:pulse:{date}`), ONE notification: + ledger rows written yesterday (Supabase count via + `ledgerService.countRowsForDate`, `n/a` when unconfigured), settles in + the last 24h (from `outcomes:{sport}:log`), odds-api quota pct, + disk/mem, `desk pack: see /desk` placeholder. +6. **Runbook** — `docs/OPS-RUNBOOK.md`: Uptime Kuma monitors, Coolify + deploy-failure webhook -> ntfy, phone subscription steps. + +## Data shapes +- `createFailureTracker(threshold=3).record(sport, result)` -> + `{ sport, count, shouldPage }` +- `zeroSettleAlarm(ledgerResults, settleable=['mlb'])` -> + `{ alarm, settled, pending }` +- `checkQuotaDaily(deps)` -> `{ alerted, deduped?, pct }` +- `getSystemHealth()` -> `{ disk_pct: 0-100|null, mem_pct: 0-100|null }` +- `buildPulseMessage(pieces)` -> single multi-line string, VOICE v1.1 + (deadpan, numbers, zero exclamation points). + +## Acceptance criteria +- Settle-pass throw -> one high-priority ntfy; zero-settle morning alarm + fires only when Postgres held pre-today unsettled rows; empty yesterday + never alerts. +- 3 consecutive bad slots page exactly once per losing streak; success + resets. +- Quota >= 80% alerts at most once per day. +- Pulse is ONE notification, fields present, `n/a` when Supabase off, + no `!` anywhere in any alert copy. +- One real test alert POSTed to the live ntfy channel (HTTP result in the + session report). Full jest green. + +## Test plan +- `tests/unit/opsWatch.test.js` — tracker (increment/page-once/reset, + per-sport isolation), zero-settle signal, morning-hour resolution, quota + dedupe (per-day, injected now), pulse assembly (fields + no-`!` lint), + recent-settle counting. +- `tests/unit/systemHealth.test.js` — injected fs/os math, statfs failure + degrades to null, threshold flags at 85/90 boundaries. +- `tests/unit/opsScheduler.test.js` — scheduler wiring: settle-throw alert, + 3-slot page via ticks, pulse fires once at 13:00 with Redis dedupe. + +## Zero out-of-pocket +ntfy.sh (existing channel) + Uptime Kuma (already on the box) + node +built-ins (`fs.statfs`, `os`). No new dependency, no paid service. diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 5b6d518..642072d 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -361,6 +361,27 @@ async function defaultGetPlayerStats(name, sport) { return { found: false }; // no free settled-result feed yet → pending } +/** + * Session 8 (A1 board, ops) — count of ledger rows for one game_date (the + * daily pulse's "rows written yesterday"). Returns null (NOT 0) when Supabase + * isn't configured or the count fails — the pulse renders "n/a", never a + * fabricated zero. + */ +async function countRowsForDate(gameDate, opts = {}) { + if (!gameDate) return null; + if (!opts.sb && !isConfigured()) return null; + try { + const sb = opts.sb || defaultClient(); + const { count, error } = await sb.from('ledger_entries') + .select('id', { count: 'exact', head: true }) + .eq('game_date', gameDate); + if (error) return null; + return count || 0; + } catch { + return null; + } +} + /** * 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate, * beat-the-close rate, pending count. Percentages are null below @@ -453,6 +474,7 @@ module.exports = { settleLedger, settleAllLedgers, applyRevision, + countRowsForDate, getModelAggregate, MIN_AGG_SAMPLE, __internals: { diff --git a/src/services/opsWatch.js b/src/services/opsWatch.js new file mode 100644 index 0000000..1af2298 --- /dev/null +++ b/src/services/opsWatch.js @@ -0,0 +1,181 @@ +'use strict'; + +/** + * opsWatch — pure ops-alarm logic for the scheduler (Session 8, A1 board). + * + * The record must never die silently. This module holds the TESTABLE pieces + * the scheduler wires to ntfy (via opsNotify): + * + * 1. createFailureTracker — per-sport CONSECUTIVE snapshot-failure counter. + * Single-slot errors are normal before books post lines; 3 in a row is a + * broken pipeline. Pages exactly once per losing streak (at the + * threshold crossing), re-arms on any good outcome. + * 2. zeroSettleAlarm — "settle pass finished but the record did not + * advance." SIGNAL CHOICE (documented): the ledger settle pass's own + * return values. settleLedger fetches `game_date < today AND outcome IS + * NULL` from POSTGRES, so the signal survives the Redis TTL expiry that + * silently killed morning settles in Session 60 — a + * snapshot:{sport}:previous read would vanish in exactly that failure + * mode, and it costs zero extra reads. Scoped to SETTLEABLE sports (mlb) + * because NBA/WNBA/soccer rows legitimately stay pending until they have + * a settled-result feed. A genuinely empty yesterday fetches 0 rows -> + * settled 0 / pending 0 -> no alarm, ever. + * 3. checkQuotaDaily — odds-api >= 80% -> one alert per day (Redis dedupe + * by date key). Complements quotaTracker's once-per-MONTH warn. + * 4. buildPulseMessage / countRecentSettles — the 9 AM ET daily pulse, one + * notification, VOICE v1.1: deadpan, numbers, no exclamation points. + * + * Everything here is pure or fully injectable — no requires of redis/ntfy. + */ + +/** Sports with a real settled-result feed (mlb game logs). Keep in sync with + * outcomeService/ledgerService's MLB-only settlement until Phase 4.5. */ +const SETTLEABLE_SPORTS = ['mlb']; + +const PAGE_THRESHOLD = 3; + +/** A snapshot result that means "the pipeline produced nothing": a hard error + * or an empty odds feed. 'no grades' (props arrived, grader refused) and 'ok' + * are NOT failures for this counter. */ +function isBadSnapshotResult(result) { + if (!result) return false; + if (result.status === 'error') return true; + return result.status === 'skipped' && String(result.reason || '') === 'no props'; +} + +/** + * Per-sport consecutive-failure counter (in-process; a restart re-arms, which + * is acceptable — the missed-cron watchdog covers a crashed scheduler). + * record() returns { sport, count, shouldPage }: shouldPage is true ONLY at + * the exact threshold crossing, so a sport that keeps failing pages once per + * losing streak, not once per slot. + */ +function createFailureTracker(threshold = PAGE_THRESHOLD) { + const counts = new Map(); + return { + record(sport, result) { + const sp = String(sport || '').toLowerCase(); + const bad = isBadSnapshotResult(result); + const count = bad ? (counts.get(sp) || 0) + 1 : 0; + counts.set(sp, count); + return { sport: sp, count, shouldPage: bad && count === threshold }; + }, + count(sport) { return counts.get(String(sport || '').toLowerCase()) || 0; }, + threshold, + }; +} + +/** The daily "close the book" slot: the first configured UTC hour >= 06 + * (hours 0-5 UTC are late-night ET slots of the PREVIOUS ET day). Falls back + * to the smallest configured hour when nothing is >= 06. */ +function morningHourUtc(hoursUtc) { + const hours = (hoursUtc || []).filter((h) => Number.isInteger(h)); + if (hours.length === 0) return 14; + const daytime = hours.filter((h) => h >= 6); + return (daytime.length > 0 ? daytime : hours).sort((a, b) => a - b)[0]; +} + +/** + * Evaluate the zero-settle signal from settleAllLedgers results. + * alarm === true only when settleable-sport rows EXISTED for settlement + * (fetched from Postgres: settled + pending > 0) and none settled. + */ +function zeroSettleAlarm(ledgerResults, settleable = SETTLEABLE_SPORTS) { + const rows = (Array.isArray(ledgerResults) ? ledgerResults : []) + .filter((r) => r && settleable.includes(String(r.sport || '').toLowerCase())); + const settled = rows.reduce((n, r) => n + (r.settled || 0), 0); + const pending = rows.reduce((n, r) => n + (r.pending || 0), 0); + return { alarm: settled === 0 && pending > 0, settled, pending }; +} + +/** ET calendar date (YYYY-MM-DD) of a Date; UTC fallback if Intl is absent. */ +function dateET(d = new Date()) { + try { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(d); + } catch { + return d.toISOString().slice(0, 10); + } +} + +/** + * Quota alert: odds-api usage >= thresholdPct -> alert once per UTC day + * (Redis dedupe key ops:quota_day:{provider}:{date}). All deps injectable: + * { getStatus, cacheGet, cacheSet, notify, now, providerId, thresholdPct }. + * Never throws — an ops check must not break the snapshot run. + */ +async function checkQuotaDaily(deps = {}) { + const providerId = deps.providerId || 'odds-api'; + const thresholdPct = deps.thresholdPct != null ? deps.thresholdPct : 0.8; + try { + const status = await deps.getStatus(providerId); + const pct = status && Number.isFinite(status.pct) ? status.pct : null; + if (pct == null || pct < thresholdPct) return { alerted: false, pct }; + const day = (deps.now ? deps.now() : new Date()).toISOString().slice(0, 10); + const key = `ops:quota_day:${providerId}:${day}`; + if (await deps.cacheGet(key)) return { alerted: false, deduped: true, pct }; + await deps.cacheSet(key, '1', 2 * 24 * 3600); + await deps.notify( + `${providerId} at ${Math.round(pct * 100)}% of ${status.quotaType || 'period'} quota ` + + `(${status.used}/${status.limit}). Blocks at 95. PropLine stays primary; ` + + `the backup path is thinning.`, + { title: 'VYNDR quota', priority: 'high', tags: ['warning', 'chart_decreasing'] }, + ); + return { alerted: true, pct }; + } catch (err) { + return { alerted: false, error: err.message }; + } +} + +/** Count settled outcomes across logs whose settledAt falls inside the last + * windowHours (default 24). Pure — feed it the outcomes:{sport}:log arrays. */ +function countRecentSettles(logs, nowMs, windowHours = 24) { + const cutoff = nowMs - windowHours * 3600 * 1000; + let n = 0; + for (const log of logs || []) { + for (const o of Array.isArray(log) ? log : []) { + const t = o && o.settledAt ? new Date(o.settledAt).getTime() : NaN; + if (Number.isFinite(t) && t >= cutoff) n += 1; + } + } + return n; +} + +/** + * Assemble the ONE daily-pulse notification body. VOICE v1.1: deadpan, + * numbers, no exclamation points. Missing data renders as "n/a" — a pulse + * never fabricates a zero. + * pieces: { dateEt, ledgerRows (number|null), settles24h (number|null), + * quota ({pct, used, limit}|null), health ({disk_pct, mem_pct}|null) } + */ +function buildPulseMessage(pieces = {}) { + const na = (v) => (v == null ? 'n/a' : String(v)); + const quota = pieces.quota && Number.isFinite(pieces.quota.pct) + ? `${Math.round(pieces.quota.pct * 100)}% (${pieces.quota.used}/${pieces.quota.limit})` + : 'n/a'; + const h = pieces.health || {}; + const disk = Number.isFinite(h.disk_pct) ? `${h.disk_pct}%` : 'n/a'; + const mem = Number.isFinite(h.mem_pct) ? `${h.mem_pct}%` : 'n/a'; + return [ + `VYNDR pulse — ${pieces.dateEt || dateET()}`, + `ledger rows yesterday: ${na(pieces.ledgerRows)}`, + `settles last 24h: ${na(pieces.settles24h)}`, + `odds-api quota: ${quota}`, + `disk ${disk} · mem ${mem}`, + 'desk pack: see /desk', + ].join('\n'); +} + +module.exports = { + createFailureTracker, + isBadSnapshotResult, + zeroSettleAlarm, + morningHourUtc, + checkQuotaDaily, + countRecentSettles, + buildPulseMessage, + dateET, + SETTLEABLE_SPORTS, + PAGE_THRESHOLD, +}; diff --git a/src/services/systemHealth.js b/src/services/systemHealth.js new file mode 100644 index 0000000..7ba31a6 --- /dev/null +++ b/src/services/systemHealth.js @@ -0,0 +1,60 @@ +'use strict'; + +/** + * systemHealth — box vitals (Session 8, A1 board — ops). + * + * Pure + injectable: disk usage via fs.promises.statfs('/'), memory via + * os.freemem/os.totalmem. Returns percentages (0-100, rounded) or null when a + * probe fails — the caller renders "n/a", never a fabricated number (Data + * Semantics Rule applies to ops copy too). Zero dependencies, zero cost. + */ + +const fsp = require('fs').promises; +const os = require('os'); + +/** Alerting thresholds — checked by the daily pulse. */ +const THRESHOLDS = { disk_pct: 85, mem_pct: 90 }; + +const roundPct = (frac) => Math.min(100, Math.max(0, Math.round(frac * 100))); + +/** + * { disk_pct, mem_pct } — each null when its probe fails. + * opts: { fsImpl (statfs), osImpl (freemem/totalmem), path }. + */ +async function getSystemHealth(opts = {}) { + const fsImpl = opts.fsImpl || fsp; + const osImpl = opts.osImpl || os; + const out = { disk_pct: null, mem_pct: null }; + try { + const s = await fsImpl.statfs(opts.path || '/'); + if (s && Number.isFinite(s.blocks) && s.blocks > 0 && Number.isFinite(s.bavail)) { + out.disk_pct = roundPct(1 - s.bavail / s.blocks); + } + } catch { /* disk_pct stays null */ } + try { + const total = osImpl.totalmem(); + const free = osImpl.freemem(); + if (Number.isFinite(total) && total > 0 && Number.isFinite(free)) { + out.mem_pct = roundPct(1 - free / total); + } + } catch { /* mem_pct stays null */ } + return out; +} + +/** + * Deadpan issue lines for anything over threshold (strictly >). Empty array = + * healthy. Copy carries the numbers; no punctuation theatrics. + */ +function healthIssues(health, thresholds = THRESHOLDS) { + const issues = []; + if (!health) return issues; + if (Number.isFinite(health.disk_pct) && health.disk_pct > thresholds.disk_pct) { + issues.push(`disk at ${health.disk_pct}% (threshold ${thresholds.disk_pct}%)`); + } + if (Number.isFinite(health.mem_pct) && health.mem_pct > thresholds.mem_pct) { + issues.push(`memory at ${health.mem_pct}% (threshold ${thresholds.mem_pct}%)`); + } + return issues; +} + +module.exports = { getSystemHealth, healthIssues, THRESHOLDS }; diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js index e726310..85edd6f 100644 --- a/src/snapshotScheduler.js +++ b/src/snapshotScheduler.js @@ -60,9 +60,19 @@ function startSnapshotScheduler(opts = {}) { const settleLedgers = opts.settleAllLedgers || require('./services/ledgerService').settleAllLedgers; const notify = opts.notify || require('./utils/opsNotify').notify; const cacheGet = opts.cacheGet || require('./utils/redis').cacheGet; + const cacheSet = opts.cacheSet || require('./utils/redis').cacheSet; const now = opts.now || (() => new Date()); + // Session 8 (A1 board) — ops: the product watches itself. Pure logic lives + // in services/opsWatch + services/systemHealth; this file only wires it. + const opsWatch = require('./services/opsWatch'); + const getSystemHealth = opts.getSystemHealth || require('./services/systemHealth').getSystemHealth; + const healthIssues = opts.healthIssues || require('./services/systemHealth').healthIssues; + const getQuotaStatus = opts.getQuotaStatus || require('./services/quotaTracker').getQuotaStatus; + const countLedgerRows = opts.countLedgerRows || require('./services/ledgerService').countRowsForDate; + const failureTracker = opts.failureTracker || opsWatch.createFailureTracker(); let lastFiredSlot = null; let lastOverdueSlot = null; + let lastPulseDate = null; // Session 56 — missed-cron watchdog. Runs every minute (independent of the // fire schedule): if a scheduled slot came and went without a snapshot, alert @@ -83,8 +93,55 @@ function startSnapshotScheduler(opts = {}) { } catch { /* watchdog must never throw */ } }; + // Session 8 — daily 9 AM ET pulse (13:00 UTC), ONE notification. Dedupe is + // belt-and-braces: in-process date + a Redis key that survives a restart + // inside the same day. Runs on the per-minute cadence, independent of slots. + const PULSE_HOUR_UTC = Number.parseInt(process.env.PULSE_HOUR_UTC || '13', 10); + const pulseTick = async () => { + try { + const d = now(); + if (d.getUTCHours() !== PULSE_HOUR_UTC || d.getUTCMinutes() !== 0) return; + const dayKey = d.toISOString().slice(0, 10); + if (dayKey === lastPulseDate) return; + lastPulseDate = dayKey; + if (await cacheGet(`ops:pulse:${dayKey}`)) return; // already sent today (pre-restart) + await cacheSet(`ops:pulse:${dayKey}`, '1', 2 * 24 * 3600); + + const yesterdayEt = opsWatch.dateET(new Date(d.getTime() - 24 * 3600 * 1000)); + let ledgerRows = null; + try { ledgerRows = await countLedgerRows(yesterdayEt); } catch { /* n/a */ } + let settles24h = null; + try { + const logs = []; + for (const sp of ['mlb', 'nba', 'wnba', 'soccer']) { + const raw = await cacheGet(`outcomes:${sp}:log`); + logs.push(Array.isArray(raw) ? raw : (raw && raw.log) || []); + } + settles24h = opsWatch.countRecentSettles(logs, d.getTime()); + } catch { /* n/a */ } + let quota = null; + try { quota = await getQuotaStatus('odds-api'); } catch { /* n/a */ } + let health = null; + try { health = await getSystemHealth(); } catch { /* n/a */ } + + await notify( + opsWatch.buildPulseMessage({ dateEt: opsWatch.dateET(d), ledgerRows, settles24h, quota, health }), + { title: 'VYNDR daily pulse', tags: ['newspaper'] }, + ); + // Box health pages separately at high priority — the pulse is a read, + // the threshold breach is an action item. + const issues = healthIssues(health); + if (issues.length > 0) { + await notify(`Box health: ${issues.join('; ')}. The record does not fit on a full disk.`, { + title: 'VYNDR box', priority: 'high', tags: ['warning'], + }); + } + } catch { /* the pulse must never break the scheduler */ } + }; + const tick = async () => { await checkOverdue(); + await pulseTick(); const d = now(); if (d.getUTCMinutes() !== 0) return; const h = d.getUTCHours(); @@ -92,20 +149,47 @@ function startSnapshotScheduler(opts = {}) { const slot = `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${h}`; if (slot === lastFiredSlot) return; // fire once per slot lastFiredSlot = slot; + // Session 8 — a settle pass that THROWS is a paged event, not a log line. + // Settlement failing quietly is how the record dies. try { const settled = await settleAll(); const totalSettled = settled.reduce((n, r) => n + (r.settled || 0), 0); console.log(`[outcomes] cron fired ${h}:00 UTC — ${totalSettled} props settled vs real results`); } catch (e) { console.warn('[outcomes] settle run failed:', e.message); + await notify(`Outcome settlement THREW at ${h}:00 UTC — ${e.message}. Yesterday's grades are unsettled until the next slot.`, { + title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'], + }); } + let ledgerResults = null; try { - const ledger = await settleLedgers(); - const n = ledger.reduce((t, r) => t + (r.settled || 0), 0); + ledgerResults = await settleLedgers(); + const n = ledgerResults.reduce((t, r) => t + (r.settled || 0), 0); console.log(`[ledger] settle pass — ${n} entries settled (outcome + CLV)`); } catch (e) { console.warn('[ledger] settle run failed:', e.message); + await notify(`Ledger settlement THREW at ${h}:00 UTC — ${e.message}. The public record did not advance.`, { + title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'], + }); } + // Session 8 — zero-settle alarm, MORNING slot only (the book-closing pass). + // Signal = the ledger settle's own Postgres-backed return values (see + // opsWatch.zeroSettleAlarm for why not snapshot:{sport}:previous). Deduped + // once per ET date; a genuinely empty yesterday (0 rows fetched) never fires. + try { + if (h === opsWatch.morningHourUtc(HOURS_UTC) && ledgerResults) { + const z = opsWatch.zeroSettleAlarm(ledgerResults); + if (z.alarm) { + const dk = `ops:settle_zero:${opsWatch.dateET(d)}`; + if (!(await cacheGet(dk))) { + await cacheSet(dk, '1', 2 * 24 * 3600); + await notify(`Morning settle pass closed with 0 settles — ${z.pending} ledger rows from before today are still pending. Yesterday had graded rows; the record did not advance.`, { + title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'], + }); + } + } + } + } catch { /* alarm evaluation must never break the tick */ } try { const results = await runAll(); const ok = results.filter((r) => r.status === 'ok'); @@ -116,9 +200,22 @@ function startSnapshotScheduler(opts = {}) { const total = results.reduce((n, r) => n + (r.gradeCount || 0), 0); await notify(`Desk pack ready — ${total} props graded across ${ok.length} sports. vyndr.app/desk`, { title: 'VYNDR desk', tags: ['newspaper'] }); } + // Session 8 — persistent-failure pager: 3+ CONSECUTIVE erroring slots for + // a sport pages once (single-slot errors are normal before lines post). + for (const r of results) { + const t = failureTracker.record(r.sport, r); + if (t.shouldPage) { + await notify(`${String(r.sport).toUpperCase()} snapshot has produced nothing for ${t.count} consecutive slots (latest: ${r.status}${r.reason ? ` — ${r.reason}` : ''}). One empty slot is a quiet book; ${t.count} is a broken pipe.`, { + title: 'VYNDR pipeline', priority: 'high', tags: ['rotating_light'], + }); + } + } } catch (e) { console.warn('[snapshot] cron run failed:', e.message); } + // Session 8 — quota check after each snapshot run: odds-api >= 80% alerts + // once per day (Redis-deduped). Never throws (guarded inside checkQuotaDaily). + await opsWatch.checkQuotaDaily({ getStatus: getQuotaStatus, cacheGet, cacheSet, notify, now: () => now() }); }; // Session 60 (night2/D) — Phase 2.5 intraday refresh. Every @@ -158,7 +255,9 @@ function startSnapshotScheduler(opts = {}) { // was invisible at boot, which made "is settlement scheduled?" unanswerable // from logs. This line makes it verifiable forever. console.log(`[settlement] armed — outcomes + ledger settle pass runs FIRST at each snapshot slot (${HOURS_UTC.join(',')} UTC), idempotent re-runs`); - return { interval, tick, refreshTick }; + // Session 8 — same verifiability rule: every watchdog states itself at boot. + console.log(`[opsWatch] armed — settle alarms (throw + morning zero-settle), failure pager (${failureTracker.threshold} consecutive), quota daily check, pulse ${PULSE_HOUR_UTC}:00 UTC`); + return { interval, tick, refreshTick, pulseTick }; } module.exports = { startSnapshotScheduler, HOURS_UTC, mostRecentExpectedSlot, isSnapshotOverdue }; diff --git a/tests/unit/opsScheduler.test.js b/tests/unit/opsScheduler.test.js new file mode 100644 index 0000000..d0d232a --- /dev/null +++ b/tests/unit/opsScheduler.test.js @@ -0,0 +1,207 @@ +'use strict'; + +// Session 8 (A1 board) — scheduler ops wiring: settle alarms, failure pager, +// quota check, daily pulse. All deps injected; zero network, zero redis. + +const { startSnapshotScheduler } = require('../../src/snapshotScheduler'); + +function memCache(seed = {}) { + const store = { ...seed }; + return { + store, + cacheGet: async (k) => (k in store ? store[k] : null), + cacheSet: async (k, v) => { store[k] = v; return true; }, + }; +} + +function collector() { + const msgs = []; + return { msgs, notify: async (m, o = {}) => { msgs.push({ m, o }); return { sent: true }; } }; +} + +// Base deps: quiet pipeline, everything healthy, injectable clock. +function baseDeps(cache, notes, dRef) { + return { + ...cache, + notify: notes.notify, + now: () => dRef.d, + runAllSnapshots: async () => [{ sport: 'mlb', status: 'ok', gradeCount: 5 }], + settleAllOutcomes: async () => [{ sport: 'mlb', settled: 3, pending: 0 }], + settleAllLedgers: async () => [{ sport: 'mlb', settled: 3, pending: 1 }], + getQuotaStatus: async () => ({ pct: 0.1, used: 50, limit: 500 }), + getSystemHealth: async () => ({ disk_pct: 40, mem_pct: 50 }), + countLedgerRows: async () => 42, + }; +} + +function makeSched(overrides = {}, startIso = '2026-07-11T14:00:00Z') { + process.env.SNAPSHOT_CRON = '1'; + const cache = memCache(); + const notes = collector(); + const dRef = { d: new Date(startIso) }; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sched = startSnapshotScheduler({ ...baseDeps(cache, notes, dRef), ...overrides }); + clearInterval(sched.interval); + logSpy.mockRestore(); + warnSpy.mockRestore(); + return { sched, cache, notes, dRef }; +} + +afterEach(() => { delete process.env.SNAPSHOT_CRON; }); + +describe('settlement alarm', () => { + test('outcome settle THROW pages high priority', async () => { + const { sched, notes } = makeSched({ + settleAllOutcomes: async () => { throw new Error('mlb stats 500'); }, + }); + await sched.tick(); + const alert = notes.msgs.find((x) => x.m.includes('Outcome settlement THREW')); + expect(alert).toBeTruthy(); + expect(alert.o.priority).toBe('high'); + expect(alert.m).toContain('mlb stats 500'); + expect(alert.m).not.toContain('!'); + }); + + test('ledger settle THROW pages high priority', async () => { + const { sched, notes } = makeSched({ + settleAllLedgers: async () => { throw new Error('supabase timeout'); }, + }); + await sched.tick(); + const alert = notes.msgs.find((x) => x.m.includes('Ledger settlement THREW')); + expect(alert).toBeTruthy(); + expect(alert.o.priority).toBe('high'); + }); + + test('morning zero-settle alarm fires once per day, only when rows were pending', async () => { + const { sched, notes, cache } = makeSched({ + settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 12 }], + }); + await sched.tick(); // 14:00 UTC — the morning slot + const alarms = notes.msgs.filter((x) => x.m.includes('0 settles')); + expect(alarms).toHaveLength(1); + expect(alarms[0].m).toContain('12 ledger rows'); + expect(alarms[0].o.priority).toBe('high'); + expect(cache.store['ops:settle_zero:2026-07-11']).toBe('1'); + expect(alarms[0].m).not.toContain('!'); + }); + + test('redis dedupe suppresses a second zero-settle alarm the same ET day', async () => { + const { sched, notes } = makeSched({ + settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 12 }], + cacheGet: async (k) => (k === 'ops:settle_zero:2026-07-11' ? '1' : null), + }); + await sched.tick(); + expect(notes.msgs.filter((x) => x.m.includes('0 settles'))).toHaveLength(0); + }); + + test('no alarm at a non-morning slot even with zero settles', async () => { + const { sched, notes } = makeSched({ + settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 12 }], + }, '2026-07-11T19:00:00Z'); + await sched.tick(); + expect(notes.msgs.filter((x) => x.m.includes('0 settles'))).toHaveLength(0); + }); + + test('genuinely empty yesterday (0 fetched rows) never alarms', async () => { + const { sched, notes } = makeSched({ + settleAllLedgers: async () => [{ sport: 'mlb', settled: 0, pending: 0 }], + }); + await sched.tick(); + expect(notes.msgs.filter((x) => x.m.includes('0 settles'))).toHaveLength(0); + }); +}); + +describe('persistent snapshot-failure pager', () => { + test('three consecutive failing slots page exactly once; success re-arms', async () => { + let result = { sport: 'mlb', status: 'skipped', reason: 'no props', gradeCount: 0 }; + const { sched, notes, dRef } = makeSched({ + runAllSnapshots: async () => [result], + }); + const slots = ['2026-07-11T14:00:00Z', '2026-07-11T19:00:00Z', '2026-07-11T22:00:00Z', '2026-07-12T01:00:00Z']; + for (const iso of slots.slice(0, 2)) { dRef.d = new Date(iso); await sched.tick(); } + expect(notes.msgs.filter((x) => x.m.includes('consecutive'))).toHaveLength(0); + dRef.d = new Date(slots[2]); await sched.tick(); // third consecutive + let pages = notes.msgs.filter((x) => x.m.includes('consecutive')); + expect(pages).toHaveLength(1); + expect(pages[0].o.priority).toBe('high'); + expect(pages[0].m).toContain('MLB'); + expect(pages[0].m).toContain('3 consecutive'); + expect(pages[0].m).not.toContain('!'); + dRef.d = new Date(slots[3]); await sched.tick(); // fourth — no re-page + expect(notes.msgs.filter((x) => x.m.includes('consecutive'))).toHaveLength(1); + // success resets, then three more failures page again + result = { sport: 'mlb', status: 'ok', gradeCount: 4 }; + dRef.d = new Date('2026-07-12T03:00:00Z'); await sched.tick(); + result = { sport: 'mlb', status: 'error', reason: 'down', gradeCount: 0 }; + for (const iso of ['2026-07-12T14:00:00Z', '2026-07-12T19:00:00Z', '2026-07-12T22:00:00Z']) { + dRef.d = new Date(iso); await sched.tick(); + } + expect(notes.msgs.filter((x) => x.m.includes('consecutive'))).toHaveLength(2); + }); +}); + +describe('quota daily check', () => { + test('>= 80% after a snapshot run alerts once per day via redis dedupe', async () => { + const { sched, notes, dRef, cache } = makeSched({ + getQuotaStatus: async () => ({ pct: 0.84, used: 420, limit: 500, quotaType: 'monthly' }), + }); + await sched.tick(); + dRef.d = new Date('2026-07-11T19:00:00Z'); + await sched.tick(); // second slot, same day — deduped + const alerts = notes.msgs.filter((x) => x.o.title === 'VYNDR quota'); + expect(alerts).toHaveLength(1); + expect(alerts[0].m).toContain('84%'); + expect(cache.store['ops:quota_day:odds-api:2026-07-11']).toBe('1'); + }); +}); + +describe('daily pulse', () => { + test('fires ONE notification at 13:00 UTC with the assembled fields', async () => { + const { sched, notes } = makeSched({}, '2026-07-11T13:00:00Z'); + await sched.tick(); + await sched.tick(); // same minute — in-process dedupe + const pulses = notes.msgs.filter((x) => x.o.title === 'VYNDR daily pulse'); + expect(pulses).toHaveLength(1); + expect(pulses[0].m).toContain('ledger rows yesterday: 42'); + expect(pulses[0].m).toContain('odds-api quota: 10% (50/500)'); + expect(pulses[0].m).toContain('disk 40% · mem 50%'); + expect(pulses[0].m).toContain('desk pack: see /desk'); + expect(pulses[0].m).not.toContain('!'); + }); + + test('redis date key suppresses a duplicate pulse after a restart', async () => { + const { sched, notes } = makeSched({ + cacheGet: async (k) => (k === 'ops:pulse:2026-07-11' ? '1' : null), + }, '2026-07-11T13:00:00Z'); + await sched.tick(); + expect(notes.msgs.filter((x) => x.o.title === 'VYNDR daily pulse')).toHaveLength(0); + }); + + test('does not fire off the pulse hour', async () => { + const { sched, notes } = makeSched({}, '2026-07-11T12:00:00Z'); + await sched.tick(); + expect(notes.msgs.filter((x) => x.o.title === 'VYNDR daily pulse')).toHaveLength(0); + }); + + test('box health over threshold pages separately at high priority', async () => { + const { sched, notes } = makeSched({ + getSystemHealth: async () => ({ disk_pct: 92, mem_pct: 50 }), + }, '2026-07-11T13:00:00Z'); + await sched.tick(); + const box = notes.msgs.filter((x) => x.o.title === 'VYNDR box'); + expect(box).toHaveLength(1); + expect(box[0].o.priority).toBe('high'); + expect(box[0].m).toContain('disk at 92%'); + expect(box[0].m).not.toContain('!'); + }); + + test('supabase unconfigured renders n/a, never a fabricated zero', async () => { + const { sched, notes } = makeSched({ + countLedgerRows: async () => null, + }, '2026-07-11T13:00:00Z'); + await sched.tick(); + const pulse = notes.msgs.find((x) => x.o.title === 'VYNDR daily pulse'); + expect(pulse.m).toContain('ledger rows yesterday: n/a'); + }); +}); diff --git a/tests/unit/opsWatch.test.js b/tests/unit/opsWatch.test.js new file mode 100644 index 0000000..9095823 --- /dev/null +++ b/tests/unit/opsWatch.test.js @@ -0,0 +1,209 @@ +'use strict'; + +// Session 8 (A1 board) — opsWatch: the pure ops-alarm logic. + +const { + createFailureTracker, + isBadSnapshotResult, + zeroSettleAlarm, + morningHourUtc, + checkQuotaDaily, + countRecentSettles, + buildPulseMessage, + dateET, +} = require('../../src/services/opsWatch'); + +function memCache(seed = {}) { + const store = { ...seed }; + return { + store, + cacheGet: async (k) => (k in store ? store[k] : null), + cacheSet: async (k, v) => { store[k] = v; return true; }, + }; +} + +describe('isBadSnapshotResult', () => { + test('error and skipped/no-props are bad; ok and skipped/no-grades are not', () => { + expect(isBadSnapshotResult({ status: 'error', reason: 'down' })).toBe(true); + expect(isBadSnapshotResult({ status: 'skipped', reason: 'no props' })).toBe(true); + expect(isBadSnapshotResult({ status: 'ok' })).toBe(false); + expect(isBadSnapshotResult({ status: 'skipped', reason: 'no grades' })).toBe(false); + expect(isBadSnapshotResult(null)).toBe(false); + }); +}); + +describe('createFailureTracker', () => { + test('pages exactly once at the third consecutive failure, stays quiet after', () => { + const t = createFailureTracker(3); + const bad = { status: 'error', reason: 'x' }; + expect(t.record('mlb', bad).shouldPage).toBe(false); // 1 + expect(t.record('mlb', bad).shouldPage).toBe(false); // 2 + const third = t.record('mlb', bad); + expect(third.shouldPage).toBe(true); // 3 -> page once + expect(third.count).toBe(3); + expect(t.record('mlb', bad).shouldPage).toBe(false); // 4 -> no re-page + expect(t.count('mlb')).toBe(4); + }); + + test('any good outcome resets the counter and re-arms the pager', () => { + const t = createFailureTracker(3); + const bad = { status: 'skipped', reason: 'no props' }; + t.record('mlb', bad); t.record('mlb', bad); + t.record('mlb', { status: 'ok' }); // reset + expect(t.count('mlb')).toBe(0); + t.record('mlb', bad); t.record('mlb', bad); + expect(t.record('mlb', bad).shouldPage).toBe(true); // re-armed -> pages again + }); + + test('counts are independent per sport', () => { + const t = createFailureTracker(3); + const bad = { status: 'error' }; + t.record('mlb', bad); t.record('mlb', bad); + expect(t.record('wnba', bad).count).toBe(1); + expect(t.count('mlb')).toBe(2); + }); +}); + +describe('zeroSettleAlarm', () => { + test('alarms when settleable rows existed and none settled', () => { + const z = zeroSettleAlarm([{ sport: 'mlb', settled: 0, pending: 12 }]); + expect(z).toEqual({ alarm: true, settled: 0, pending: 12 }); + }); + + test('never alarms on a genuinely empty yesterday (0 rows fetched)', () => { + expect(zeroSettleAlarm([{ sport: 'mlb', settled: 0, pending: 0 }]).alarm).toBe(false); + expect(zeroSettleAlarm([]).alarm).toBe(false); + expect(zeroSettleAlarm(null).alarm).toBe(false); + }); + + test('no alarm when anything settled', () => { + expect(zeroSettleAlarm([{ sport: 'mlb', settled: 3, pending: 9 }]).alarm).toBe(false); + }); + + test('ignores sports without a settled-result feed (WNBA pendings are honest)', () => { + const z = zeroSettleAlarm([ + { sport: 'mlb', settled: 0, pending: 0 }, + { sport: 'wnba', settled: 0, pending: 40 }, + ]); + expect(z.alarm).toBe(false); + }); +}); + +describe('morningHourUtc', () => { + test('first configured hour >= 06 UTC (1,3 are late-night ET of the prior day)', () => { + expect(morningHourUtc([14, 19, 22, 1, 3])).toBe(14); + expect(morningHourUtc([19, 14])).toBe(14); + }); + test('falls back to the smallest hour when nothing is daytime', () => { + expect(morningHourUtc([1, 3])).toBe(1); + expect(morningHourUtc([])).toBe(14); + }); +}); + +describe('checkQuotaDaily', () => { + const notifyCollector = () => { + const msgs = []; + return { msgs, notify: async (m, o) => { msgs.push({ m, o }); return { sent: true }; } }; + }; + + test('below 80% -> no alert', async () => { + const { msgs, notify } = notifyCollector(); + const cache = memCache(); + const r = await checkQuotaDaily({ + getStatus: async () => ({ pct: 0.5, used: 250, limit: 500 }), + ...cache, notify, now: () => new Date('2026-07-11T15:00:00Z'), + }); + expect(r.alerted).toBe(false); + expect(msgs).toHaveLength(0); + }); + + test('>= 80% alerts once, then dedupes for the rest of the day', async () => { + const { msgs, notify } = notifyCollector(); + const cache = memCache(); + const deps = { + getStatus: async () => ({ pct: 0.82, used: 410, limit: 500, quotaType: 'monthly' }), + ...cache, notify, now: () => new Date('2026-07-11T15:00:00Z'), + }; + expect((await checkQuotaDaily(deps)).alerted).toBe(true); + const second = await checkQuotaDaily(deps); + expect(second.alerted).toBe(false); + expect(second.deduped).toBe(true); + expect(msgs).toHaveLength(1); + expect(msgs[0].m).toContain('82%'); + expect(msgs[0].m).toContain('410/500'); + expect(msgs[0].o.priority).toBe('high'); + expect(msgs[0].m).not.toContain('!'); + expect(cache.store['ops:quota_day:odds-api:2026-07-11']).toBe('1'); + }); + + test('fires again the next day (date-keyed dedupe)', async () => { + const { msgs, notify } = notifyCollector(); + const cache = memCache(); + const base = { + getStatus: async () => ({ pct: 0.9, used: 450, limit: 500 }), + ...cache, notify, + }; + await checkQuotaDaily({ ...base, now: () => new Date('2026-07-11T15:00:00Z') }); + await checkQuotaDaily({ ...base, now: () => new Date('2026-07-12T15:00:00Z') }); + expect(msgs).toHaveLength(2); + }); + + test('never throws — a broken status probe returns { alerted: false }', async () => { + const r = await checkQuotaDaily({ getStatus: async () => { throw new Error('redis gone'); } }); + expect(r.alerted).toBe(false); + expect(r.error).toBe('redis gone'); + }); +}); + +describe('countRecentSettles', () => { + test('counts settledAt inside the window across multiple sport logs', () => { + const nowMs = Date.parse('2026-07-11T13:00:00Z'); + const logs = [ + [ { settledAt: '2026-07-11T14:05:00Z' }, { settledAt: '2026-07-09T14:00:00Z' } ], // 1 in, 1 out + [ { settledAt: '2026-07-10T14:30:00Z' }, { noSettledAt: true } ], // 1 in + ]; + expect(countRecentSettles(logs, nowMs)).toBe(2); + expect(countRecentSettles([], nowMs)).toBe(0); + expect(countRecentSettles(null, nowMs)).toBe(0); + }); +}); + +describe('buildPulseMessage', () => { + test('one message with every field, real numbers rendered', () => { + const msg = buildPulseMessage({ + dateEt: '2026-07-11', + ledgerRows: 42, + settles24h: 38, + quota: { pct: 0.12, used: 61, limit: 500 }, + health: { disk_pct: 41, mem_pct: 63 }, + }); + expect(msg).toContain('VYNDR pulse — 2026-07-11'); + expect(msg).toContain('ledger rows yesterday: 42'); + expect(msg).toContain('settles last 24h: 38'); + expect(msg).toContain('odds-api quota: 12% (61/500)'); + expect(msg).toContain('disk 41% · mem 63%'); + expect(msg).toContain('desk pack: see /desk'); + }); + + test('missing data renders n/a — never a fabricated zero', () => { + const msg = buildPulseMessage({ dateEt: '2026-07-11', ledgerRows: null, settles24h: null, quota: null, health: null }); + expect(msg).toContain('ledger rows yesterday: n/a'); + expect(msg).toContain('settles last 24h: n/a'); + expect(msg).toContain('odds-api quota: n/a'); + expect(msg).toContain('disk n/a · mem n/a'); + }); + + test('VOICE v1.1 — no exclamation points, ever', () => { + const loud = buildPulseMessage({ ledgerRows: 999, settles24h: 999, quota: { pct: 0.99, used: 495, limit: 500 }, health: { disk_pct: 99, mem_pct: 99 } }); + expect(loud).not.toContain('!'); + expect(buildPulseMessage({})).not.toContain('!'); + }); +}); + +describe('dateET', () => { + test('renders the America/New_York calendar date (late UTC rolls back)', () => { + // 03:00 UTC Jul 11 = 11:00 PM ET Jul 10. + expect(dateET(new Date('2026-07-11T03:00:00Z'))).toBe('2026-07-10'); + expect(dateET(new Date('2026-07-11T15:00:00Z'))).toBe('2026-07-11'); + }); +}); diff --git a/tests/unit/systemHealth.test.js b/tests/unit/systemHealth.test.js new file mode 100644 index 0000000..c9ceb9d --- /dev/null +++ b/tests/unit/systemHealth.test.js @@ -0,0 +1,58 @@ +'use strict'; + +// Session 8 (A1 board) — systemHealth: box vitals, pure + injectable. + +const { getSystemHealth, healthIssues, THRESHOLDS } = require('../../src/services/systemHealth'); + +describe('getSystemHealth', () => { + test('computes disk_pct from statfs and mem_pct from os', async () => { + const fsImpl = { statfs: async () => ({ blocks: 1000, bavail: 400 }) }; // 60% used + const osImpl = { totalmem: () => 100, freemem: () => 25 }; // 75% used + const h = await getSystemHealth({ fsImpl, osImpl }); + expect(h).toEqual({ disk_pct: 60, mem_pct: 75 }); + }); + + test('a failed statfs degrades disk_pct to null, mem still reports', async () => { + const fsImpl = { statfs: async () => { throw new Error('EACCES'); } }; + const osImpl = { totalmem: () => 10, freemem: () => 1 }; + const h = await getSystemHealth({ fsImpl, osImpl }); + expect(h.disk_pct).toBeNull(); + expect(h.mem_pct).toBe(90); + }); + + test('zero/invalid totals degrade to null rather than dividing by zero', async () => { + const h = await getSystemHealth({ + fsImpl: { statfs: async () => ({ blocks: 0, bavail: 0 }) }, + osImpl: { totalmem: () => 0, freemem: () => 0 }, + }); + expect(h).toEqual({ disk_pct: null, mem_pct: null }); + }); + + test('real (uninjected) call returns numbers on this box', async () => { + const h = await getSystemHealth(); + expect(h.disk_pct === null || (h.disk_pct >= 0 && h.disk_pct <= 100)).toBe(true); + expect(h.mem_pct).toBeGreaterThanOrEqual(0); + expect(h.mem_pct).toBeLessThanOrEqual(100); + }); +}); + +describe('healthIssues', () => { + test('thresholds: disk > 85, mem > 90 — strictly greater', () => { + expect(healthIssues({ disk_pct: 85, mem_pct: 90 })).toEqual([]); + expect(healthIssues({ disk_pct: 86, mem_pct: 90 })).toEqual(['disk at 86% (threshold 85%)']); + expect(healthIssues({ disk_pct: 40, mem_pct: 91 })).toEqual(['memory at 91% (threshold 90%)']); + expect(healthIssues({ disk_pct: 99, mem_pct: 99 })).toHaveLength(2); + }); + + test('null probes never flag; copy carries no exclamation points', () => { + expect(healthIssues({ disk_pct: null, mem_pct: null })).toEqual([]); + expect(healthIssues(null)).toEqual([]); + for (const line of healthIssues({ disk_pct: 99, mem_pct: 99 })) { + expect(line).not.toContain('!'); + } + }); + + test('exported thresholds match the spec', () => { + expect(THRESHOLDS).toEqual({ disk_pct: 85, mem_pct: 90 }); + }); +});