← back to Dw Marketing Reels
scripts/check-publish-status.mjs
65 lines
#!/usr/bin/env node
/**
* check-publish-status.mjs — TK-10395 safety net.
*
* The nightly publisher (scripts/publish-social.mjs) deliberately never hard-fails
* the cron job (it exits 0 so a bad IG post can't abort the render+push). It records
* the real outcome per channel on the newest reel in data/reels.json as
* reels[0].publish.instagram = { status, note, http, ... }
*
* This READ-ONLY check reads that structured state AFTER a run and exits non-zero when
* the Instagram publish did NOT actually land while the pipeline was ARMED — so a
* recurrence of the 2207076 container-processing failure (or any publish error) is
* caught the morning after instead of silently going green. $0, no network.
*
* Exit 0 = healthy / not-armed / nothing-to-check. Exit 1 = armed-but-failed.
* Usage: node scripts/check-publish-status.mjs (optionally READ a pushed copy via REELS_JSON=...)
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const MAN = process.env.REELS_JSON || join(ROOT, 'data', 'reels.json');
const ARMED = process.env.SOCIAL_LIVE_ARMED === '1';
function fail(msg) { console.error(`[check-publish] FAIL: ${msg}`); process.exit(1); }
function ok(msg) { console.log(`[check-publish] OK: ${msg}`); process.exit(0); }
let reels;
try { reels = JSON.parse(readFileSync(MAN, 'utf8')); }
catch (e) { ok(`no readable reels.json (${e.message}) — nothing to check`); }
const list = Array.isArray(reels) ? reels : (reels.reels || reels.items || []);
if (!list.length) ok('reels.json has no entries — nothing to check');
const reel = list[0]; // publisher prepends newest
const ig = reel && reel.publish && reel.publish.instagram;
if (!ig) ok(`newest reel (${reel && reel.file}) has no instagram publish record yet`);
const status = ig.status || 'unknown';
const detail = `${reel.file} → instagram: ${status}${ig.note ? ' — ' + ig.note : ''}${ig.http ? ' [http ' + ig.http + ']' : ''}`;
// Healthy terminal states (posted, or the deliberate not-yet-live gates).
// NOTE: 'posted-unverified' is deliberately NOT here — it means the client timed out AFTER
// sending the request, so the post OUTCOME IS UNKNOWN. It is handled explicitly below.
const GOOD = ['posted', 'simulated', 'ready-pending-arm', 'held-claims-review'];
// Gate states that are expected while NOT armed but are a real failure once armed.
const GATE = ['pending-creds', 'no-creds-for-account', 'ready-pending-arm'];
if (GOOD.includes(status) && status !== 'ready-pending-arm') ok(detail);
if (!ARMED && (GATE.includes(status) || status === 'error' || status === 'posted-unverified')) ok(`(not armed) ${detail}`);
// 'posted-unverified' = client timed out after the request was SENT (outcome UNKNOWN) AND the
// publisher's dup-guard SKIPS this reel on the next run. So if the post actually failed it would
// silently never land while the monitor stayed green — the exact TK-10395 silent-no-post class
// (contrarian find: the old GOOD-list swallowed this into a false green). When ARMED, refuse to go
// green on an unknown outcome — fail LOUD so it's verified on IG out-of-band.
if (ARMED && status === 'posted-unverified') fail(`armed post UNVERIFIED (client timeout — outcome unknown, dup-guard will skip next run) — verify on IG: ${detail}`);
// Armed + not a healthy post = the TK-10395 failure mode (incl. 2207076 after retry exhaust).
if (status === 'error' || GATE.includes(status)) fail(`armed IG publish did not land — ${detail}`);
if (/2207076|attempt 3\/3|Media upload has failed/i.test(ig.note || '')) fail(`transient-retry exhausted — ${detail}`);
ok(detail);