diff --git a/Dockerfile b/Dockerfile index fb410e4..1608f76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,12 @@ COPY src ./src COPY poller ./poller COPY scripts ./scripts COPY supabase ./supabase +# Session 65 (P0) — content/ holds the Stark-line library + seed articles. +# It was NOT copied, so mediaEngine's module-load read threw ENOENT in the +# image and crashed the API at boot (the deploy silently rolled back for 14 +# hours). The read is now hardened to optional too (belt AND suspenders), +# but the file belongs in the image. +COPY content ./content # Persistent volume for JSONL training data (resolutions survive # redeploys via the Coolify mount). PM2_HOME lives outside it so diff --git a/src/preflight.js b/src/preflight.js new file mode 100644 index 0000000..7b4ac2e --- /dev/null +++ b/src/preflight.js @@ -0,0 +1,69 @@ +'use strict'; + +/** + * preflight — boot visibility (Session 65, Standard §A4). + * + * A healthcheck that only pings /api/health can't tell "booted degraded" + * from "crashed and rolled back" — which is exactly how a missing garnish + * file (content/stark-lines.json) served a 14-hour-old image silently. This + * runs once at boot and prints ONE unambiguous line: `[preflight] OK` when + * everything required is present, or `[preflight] DEGRADED` naming exactly + * what is missing. It NEVER throws and NEVER blocks boot — its only job is + * to make the container's state legible in the logs. + * + * Philosophy (matches the app's own doctrine): content garnish missing is + * NOTED, not fatal (absent beats crashed). Missing critical env is named so + * an operator sees it immediately instead of debugging a symptom later. + */ + +const fs = require('fs'); +const path = require('path'); + +// Optional content — the app runs fine without these; we just name them so a +// missing one is visible (and never silently crashes a require chain again). +const OPTIONAL_CONTENT = [ + { label: 'stark-lines.json', file: path.join(__dirname, '..', 'content', 'stark-lines.json'), note: 'media posts drop the Stark kicker' }, + { label: 'coaches.json', file: path.join(__dirname, 'config', 'coaches.json'), note: 'coach-impact feature uses no seed' }, +]; + +// Critical env — the app boots without these but key surfaces degrade; named +// loudly so an operator fixes them before hunting a downstream symptom. +const CRITICAL_ENV = ['SUPABASE_URL', 'REDIS_URL', 'VYNDR_INTERNAL_KEY']; +// Either service-role name satisfies the Supabase write path. +const ENV_EITHER = [['SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_SERVICE_KEY']]; + +function runPreflight(log = console) { + try { + const missingContent = []; + for (const c of OPTIONAL_CONTENT) { + let ok = false; + try { fs.accessSync(c.file, fs.constants.R_OK); ok = true; } catch { ok = false; } + if (!ok) missingContent.push(`${c.label} (${c.note})`); + } + + const missingEnv = CRITICAL_ENV.filter((k) => !process.env[k]); + for (const group of ENV_EITHER) { + if (!group.some((k) => process.env[k])) missingEnv.push(group.join('|')); + } + + // Operational visibility for the pipeline knobs (not failures — states). + const cron = process.env.SNAPSHOT_CRON === '1' ? 'armed' : 'off'; + const interval = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0 + ? `${process.env.SNAPSHOT_EXPECTED_INTERVAL}s` : 'default(18000s)'; + + if (missingContent.length === 0 && missingEnv.length === 0) { + log.log(`[preflight] OK — content present, critical env present, snapshot cron ${cron}, expected-interval ${interval}`); + } else { + log.warn(`[preflight] DEGRADED — snapshot cron ${cron}, expected-interval ${interval}`); + if (missingEnv.length) log.warn(`[preflight] missing env: ${missingEnv.join(', ')}`); + if (missingContent.length) log.warn(`[preflight] missing content (non-fatal): ${missingContent.join(', ')}`); + } + return { ok: missingContent.length === 0 && missingEnv.length === 0, missingContent, missingEnv }; + } catch (e) { + // Preflight itself must never break boot. + try { log.warn(`[preflight] check errored (non-fatal): ${e.message}`); } catch { /* ignore */ } + return { ok: false, error: e.message }; + } +} + +module.exports = { runPreflight, __internals: { OPTIONAL_CONTENT, CRITICAL_ENV, ENV_EITHER } }; diff --git a/src/server.js b/src/server.js index 1be31b9..4c1e551 100644 --- a/src/server.js +++ b/src/server.js @@ -1,3 +1,8 @@ +// Session 65 (§A4) — preflight BEFORE anything else prints, so the container's +// state (content present? critical env present? cron armed?) is the first +// thing in the logs, not something inferred after a silent rollback. +require('./preflight').runPreflight(); + const app = require('./app'); // Session 20 — surface which providers are actually configured at // boot. A silently-missing key (e.g. ODDSPAPI_KEY unset in prod) diff --git a/src/services/mediaEngine.js b/src/services/mediaEngine.js index 68c409c..a87bf15 100644 --- a/src/services/mediaEngine.js +++ b/src/services/mediaEngine.js @@ -20,7 +20,24 @@ const fs = require('fs'); const path = require('path'); -const STARK = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'content', 'stark-lines.json'), 'utf8')); +// Session 65 (P0 deploy fix) — the Stark-layer library is a GARNISH, never +// load-bearing. This used to be an unguarded module-load readFileSync; when +// the file wasn't in the container image it threw at require time, which +// (via app.js → routes/desk → deskService → here) crashed the WHOLE API at +// boot and silently rolled the deploy back for 14 hours. A witty kicker is +// not allowed to take down the record. Missing/corrupt file → empty library +// → every format posts WITHOUT the Stark line (starkLine returns null), +// which is a valid, still-VOICE-compliant post. Never throws. +const STARK_PATH = path.join(__dirname, '..', '..', 'content', 'stark-lines.json'); +function loadStark() { + try { + return JSON.parse(fs.readFileSync(STARK_PATH, 'utf8')); + } catch (e) { + console.warn(`[mediaEngine] stark-lines.json unavailable (${e.code || e.message}); posting without the Stark layer.`); + return {}; + } +} +const STARK = loadStark(); // ---- VOICE lint ----------------------------------------------------------- const BANNED_PATTERNS = [ diff --git a/tests/unit/bootResilience.test.js b/tests/unit/bootResilience.test.js new file mode 100644 index 0000000..37361af --- /dev/null +++ b/tests/unit/bootResilience.test.js @@ -0,0 +1,105 @@ +// Session 65 (P0 deploy fix) — the API crashed at boot because a GARNISH +// file (content/stark-lines.json) was a hard module-load readFileSync and +// wasn't in the container image. These tests lock the fix: the media engine +// boots and functions with the file ABSENT, and the preflight names what's +// missing without ever throwing. + +describe('mediaEngine — boots and posts with the Stark library absent (§L2)', () => { + let media; + beforeEach(() => { + jest.resetModules(); + // Make EVERY stark-lines.json read throw ENOENT — simulate the file + // missing from the image (the exact production failure). + jest.doMock('fs', () => { + const real = jest.requireActual('fs'); + return { + ...real, + readFileSync: (p, ...rest) => { + if (String(p).includes('stark-lines.json')) { + const err = new Error('ENOENT: no such file'); + err.code = 'ENOENT'; + throw err; + } + return real.readFileSync(p, ...rest); + }, + }; + }); + media = require('../../src/services/mediaEngine'); + }); + afterEach(() => { jest.dontMock('fs'); jest.resetModules(); }); + + test('requiring the module does NOT throw (this was the boot crash)', () => { + expect(media).toBeTruthy(); + expect(media.__internals.STARK).toEqual({}); // empty library, not a crash + }); + + test('starkLine returns null with no library — the garnish is simply absent', () => { + expect(media.starkLine('morning', '2026-07-12T14:00:00Z')).toBeNull(); + }); + + test('formats still produce valid, VOICE-clean posts without the kicker', () => { + const wire = media.morningWire({ dateIso: '2026-07-12T14:00:00Z', counts: { mlb: 15 }, loudest: null }); + expect(wire).toContain('15 MLB.'); + expect(wire).toContain('vyndr.app'); + expect(media.lintVoice(wire)).toEqual([]); + + const settle = media.theSettle({ + aggregate: { settled: 5, hits: 3, misses: 2, pushes: 0, hit_pct: null, pending: 10, by_tier: {} }, + misses: [{ player_name: 'A Guy', stat: 'points', line: 23.5, side: 'over' }], + dateIso: '2026-07-12T14:00:00Z', + }); + expect(settle).toContain('All grades: 3-2'); + expect(media.lintVoice(settle)).toEqual([]); + }); +}); + +describe('deskService assembles with the Stark library absent', () => { + test('variants() tolerates an empty STARK (no throw, no kicker to strip)', () => { + jest.resetModules(); + jest.doMock('fs', () => { + const real = jest.requireActual('fs'); + return { + ...real, + readFileSync: (p, ...rest) => { + if (String(p).includes('stark-lines.json')) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } + return real.readFileSync(p, ...rest); + }, + }; + }); + const { assembleDeskPack } = require('../../src/services/deskService'); + expect(typeof assembleDeskPack).toBe('function'); + jest.dontMock('fs'); + jest.resetModules(); + }); +}); + +describe('preflight — names what is missing, never throws (§A4)', () => { + const { runPreflight } = require('../../src/preflight'); + + test('reports OK with a captured logger when content + env are present', () => { + const logs = []; + const fake = { log: (m) => logs.push(['log', m]), warn: (m) => logs.push(['warn', m]) }; + const prev = { SUPABASE_URL: process.env.SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, REDIS_URL: process.env.REDIS_URL, VYNDR_INTERNAL_KEY: process.env.VYNDR_INTERNAL_KEY }; + process.env.SUPABASE_URL = 'x'; process.env.SUPABASE_SERVICE_ROLE_KEY = 'x'; process.env.REDIS_URL = 'x'; process.env.VYNDR_INTERNAL_KEY = 'x'; + const res = runPreflight(fake); + // content/stark-lines.json + src/config/coaches.json both exist in the tree. + expect(res.ok).toBe(true); + expect(logs.some(([lvl, m]) => lvl === 'log' && m.includes('[preflight] OK'))).toBe(true); + Object.assign(process.env, prev); + for (const k of Object.keys(prev)) if (prev[k] === undefined) delete process.env[k]; + }); + + test('names missing env, does not throw, returns not-ok', () => { + const logs = []; + const fake = { log: (m) => logs.push(m), warn: (m) => logs.push(m) }; + const prev = { SUPABASE_URL: process.env.SUPABASE_URL, REDIS_URL: process.env.REDIS_URL, VYNDR_INTERNAL_KEY: process.env.VYNDR_INTERNAL_KEY, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, SUPABASE_SERVICE_KEY: process.env.SUPABASE_SERVICE_KEY }; + delete process.env.SUPABASE_URL; delete process.env.REDIS_URL; delete process.env.VYNDR_INTERNAL_KEY; + delete process.env.SUPABASE_SERVICE_ROLE_KEY; delete process.env.SUPABASE_SERVICE_KEY; + const res = runPreflight(fake); + expect(res.ok).toBe(false); + expect(res.missingEnv).toEqual(expect.arrayContaining(['SUPABASE_URL', 'REDIS_URL', 'VYNDR_INTERNAL_KEY'])); + expect(logs.some((m) => m.includes('DEGRADED'))).toBe(true); + Object.assign(process.env, prev); + for (const k of Object.keys(prev)) if (prev[k] === undefined) delete process.env[k]; + }); +});