fix(brief): digest Dockerfile + propagate compose failure to exit code

Addresses two seventh-round review findings on PR #3157.

1. Cross-directory imports + current Railway build root (todo 230).
   The consolidated digest cron imports from ../api, ../shared, and
   (transitively via scripts/lib/brief-compose.mjs) ../server/_shared.
   The running digest-notifications Railway service builds from the
   scripts/ root — those parent paths are outside the deploy tree
   and would 500 on next rebuild with ERR_MODULE_NOT_FOUND.

   New Dockerfile.digest-notifications (repo-root build context)
   COPYs exactly the modules the cron needs: scripts/ contents,
   scripts/lib/, shared/brief-envelope.*, shared/brief-filter.*,
   server/_shared/brief-render.*, api/_upstash-json.js,
   api/_seed-envelope.js. Tight list to keep the watch surface small.
   Pattern matches the retired Dockerfile.seed-brief-composer + the
   existing Dockerfile.relay.

2. Silent compose failures (todo 231). composeBriefsForRun logged
   counters but never exited non-zero. An Upstash outage or missing
   signing secret silently dropped every brief write while Railway
   showed the cron green. The retired standalone composer exited 1
   on structural failures; that observability was lost in the
   consolidation.

   Changed the compose fn to return {briefByUser, composeSuccess,
   composeFailed}. Main captures the counters, runs the full digest
   send loop first (compose-layer breakage must NEVER block user-
   visible digest delivery), then calls shouldExitNonZero at the
   very end. Exit-on-failure gives ops the Railway-red signal
   without touching send behaviour.

   Also: a total read failure of news:insights:v1 (catch branch)
   now counts as 1 compose failure so the gate trips on insights-
   key infra breakage, not just per-user write failures.

Tests unchanged (98/98). Typecheck + node --check clean. Biome
complexity ticks 63→65 — same pre-existing bucket, already tolerated
by CI; no new blocker.

PRE-MERGE Railway work still pending: set BRIEF_URL_SIGNING_SECRET
+ WORLDMONITOR_PUBLIC_BASE_URL on the digest-notifications service,
AND switch its dockerfilePath to /Dockerfile.digest-notifications
before merging. Without the dockerfilePath switch, the next rebuild
fails.
This commit is contained in:
Elie Habib
2026-04-18 11:36:59 +04:00
parent fcbf05244d
commit 35a46aa34b
2 changed files with 95 additions and 14 deletions

View File

@@ -0,0 +1,56 @@
# =============================================================================
# Digest notifications cron (consolidated: digest + brief compose + channel send)
# =============================================================================
# Runs scripts/seed-digest-notifications.mjs as a Railway cron (every 30 min).
# The script now also owns the brief envelope write path — per-user
# brief:{userId}:{issueDate} keys are produced here, and every channel
# dispatch (email/telegram/slack/discord) gets a signed magazine URL CTA.
#
# Historical context: before 2026-04-18 this service built from the
# scripts/ root with plain `npm ci`. The consolidation PR introduced
# cross-directory imports (shared/*, server/_shared/*, api/*) so the
# service now needs repo-root as build context with the specific
# modules COPY'd in. The retired seed-brief-composer Dockerfile had
# the same pattern.
#
# Required env (Railway service vars):
# UPSTASH_REDIS_REST_URL
# UPSTASH_REDIS_REST_TOKEN
# CONVEX_URL (or CONVEX_SITE_URL)
# RELAY_SHARED_SECRET
# RESEND_API_KEY
# TELEGRAM_BOT_TOKEN
# BRIEF_URL_SIGNING_SECRET (brief compose disabled without this)
# WORLDMONITOR_PUBLIC_BASE_URL (defaults to https://worldmonitor.app)
# Optional:
# DIGEST_CRON_ENABLED=0 (kill switch for the whole cron)
# BRIEF_COMPOSE_ENABLED=0 (kill switch for just brief compose)
# AI_DIGEST_ENABLED=0 (kill switch for AI summary LLM call)
# =============================================================================
FROM node:22-alpine
WORKDIR /app
# Install scripts/ runtime dependencies (resend, convex, etc.).
COPY scripts/package.json scripts/package-lock.json ./scripts/
RUN npm ci --prefix scripts --omit=dev
# Digest cron + shared script helpers it imports via createRequire.
COPY scripts/seed-digest-notifications.mjs ./scripts/
COPY scripts/_digest-markdown.mjs ./scripts/
COPY scripts/lib/ ./scripts/lib/
# Brief envelope contract + filter + renderer assertion. These live
# under shared/ and server/_shared/ in the repo and are imported from
# scripts/lib/brief-compose.mjs. Keep this COPY list tight — adding
# unrelated shared/* files expands the rebuild watch surface.
COPY shared/brief-envelope.js shared/brief-envelope.d.ts ./shared/
COPY shared/brief-filter.js shared/brief-filter.d.ts ./shared/
COPY server/_shared/brief-render.js server/_shared/brief-render.d.ts ./server/_shared/
# Upstash REST helper (brief compose uses redisPipeline + readRawJson).
COPY api/_upstash-json.js ./api/
COPY api/_seed-envelope.js ./api/
CMD ["node", "scripts/seed-digest-notifications.mjs"]

View File

@@ -34,6 +34,7 @@ import {
composeBriefForRule,
extractInsights,
groupEligibleRulesByUser,
shouldExitNonZero as shouldExitOnBriefFailures,
} from './lib/brief-compose.mjs';
import { signBriefUrl, BriefUrlError } from './lib/brief-url-sign.mjs';
@@ -881,29 +882,39 @@ function injectBriefCta(html, magazineUrl) {
/**
* Write brief:{userId}:{issueDate} for every eligible user and
* return a Map keyed by userId with { envelope, magazineUrl } for
* the digest loop to consume. One brief per user regardless of how
* many variants they have enabled.
* return { briefByUser, counters } for the digest loop + main's
* end-of-run exit gate. One brief per user regardless of how many
* variants they have enabled.
*
* Returns an empty Map when brief composition is disabled, insights
* are unavailable, or the signing secret is missing. Never throws —
* the digest send path must remain independent of the brief path.
* Returns empty counters when brief composition is disabled,
* insights are unavailable, or the signing secret is missing. Never
* throws — the digest send path must remain independent of the
* brief path, so main() handles exit-codes at the very end AFTER
* the digest has been dispatched.
*
* @param {unknown[]} rules
* @param {number} nowMs
* @returns {Promise<{ briefByUser: Map<string, object>; composeSuccess: number; composeFailed: number }>}
*/
async function composeBriefsForRun(rules, nowMs) {
const briefByUser = new Map();
if (!BRIEF_COMPOSE_ENABLED) return briefByUser;
if (!BRIEF_COMPOSE_ENABLED) return { briefByUser, composeSuccess: 0, composeFailed: 0 };
let insightsRaw = null;
try {
insightsRaw = await readRawJsonFromUpstash(INSIGHTS_KEY);
} catch (err) {
console.warn('[digest] brief: insights read failed, skipping brief composition:', err.message);
return briefByUser;
// An infra-level read failure is a compose-layer failure worth
// the Railway red-flag — count it as one failure so the exit
// gate catches it. We still return a valid shape so the digest
// send path runs normally.
return { briefByUser, composeSuccess: 0, composeFailed: 1 };
}
if (!insightsRaw) return briefByUser;
if (!insightsRaw) return { briefByUser, composeSuccess: 0, composeFailed: 0 };
const insights = extractInsights(insightsRaw);
if (insights.topStories.length === 0) return briefByUser;
if (insights.topStories.length === 0) return { briefByUser, composeSuccess: 0, composeFailed: 0 };
const eligibleByUser = groupEligibleRulesByUser(rules);
let composeSuccess = 0;
@@ -927,7 +938,7 @@ async function composeBriefsForRun(rules, nowMs) {
console.log(
`[digest] brief: compose_success=${composeSuccess} compose_failed=${composeFailed} total_users=${eligibleByUser.size}`,
);
return briefByUser;
return { briefByUser, composeSuccess, composeFailed };
}
/**
@@ -1003,9 +1014,11 @@ async function main() {
// Compose per-user brief envelopes once per run (extracted so main's
// complexity score stays in the biome budget). Failures MUST NOT
// block digest sends — worst case the digest goes out without the
// magazine CTA.
const briefByUser = await composeBriefsForRun(rules, nowMs);
// block digest sends — we carry counters forward and apply the
// exit-non-zero gate AFTER the digest dispatch so Railway still
// surfaces compose-layer breakage without skipping user-visible
// digest delivery.
const { briefByUser, composeSuccess, composeFailed } = await composeBriefsForRun(rules, nowMs);
let sentCount = 0;
@@ -1113,6 +1126,18 @@ async function main() {
}
console.log(`[digest] Cron run complete: ${sentCount} digest(s) sent`);
// Brief-compose failure gate. Runs at the very end so a compose-
// layer outage (Upstash blip, insights key stale, signing secret
// missing) never blocks digest delivery to users — but Railway
// still flips the run red so ops see the signal. Denominator is
// attempted writes (shouldExitNonZero enforces this).
if (shouldExitOnBriefFailures({ success: composeSuccess, failed: composeFailed })) {
console.warn(
`[digest] brief: exiting non-zero — compose_failed=${composeFailed} compose_success=${composeSuccess} crossed the threshold`,
);
process.exit(1);
}
}
main().catch((err) => {