diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 60d9e8683..b34726a0b 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -4056,3 +4056,4 @@ } } } + diff --git a/scripts/package.json b/scripts/package.json index e0d50d5f8..5bad7b6d2 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,7 +6,8 @@ "scripts": { "start": "node ais-relay.cjs", "notification-relay": "node notification-relay.cjs", - "telegram:session": "node telegram/session-auth.mjs" + "telegram:session": "node telegram/session-auth.mjs", + "seed-bundle-regional": "node seed-bundle-regional.mjs" }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", diff --git a/scripts/seed-bundle-regional.mjs b/scripts/seed-bundle-regional.mjs new file mode 100644 index 000000000..8247ee79b --- /dev/null +++ b/scripts/seed-bundle-regional.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// @ts-check +/** + * Regional Intelligence seed bundle. + * + * Single Railway cron entry point that runs: + * 1. seed-regional-snapshots.mjs — ALWAYS (6h snapshot compute) + * 2. seed-regional-briefs.mjs — WEEKLY (LLM weekly brief, skipped + * if the last brief seed-meta is younger than 6.5 days) + * + * Railway cron: every 6 hours (cron: 0 [star]/6 [star] [star] [star]) + * rootDirectory: scripts + * startCommand: node seed-bundle-regional.mjs + * (Railway executes from rootDirectory, so NO scripts/ prefix) + * watchPaths: scripts/seed-bundle-regional.mjs, scripts/seed-regional-*.mjs, + * scripts/regional-snapshot/**, scripts/shared/** + * + * NOTE: both sub-seeders are imported in-process (not child_process.execFile) + * because they were explicitly refactored to throw on failure instead of + * calling process.exit(1). If either script re-introduces process.exit() + * inside main(), the bundle will die before the second seeder runs. + * + * Env vars needed (same as the individual scripts): + * UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN + * GROQ_API_KEY and/or OPENROUTER_API_KEY (for narrative + brief LLM) + */ + +import { loadEnvFile, getRedisCredentials } from './_seed-utils.mjs'; +import { main as runSnapshots } from './seed-regional-snapshots.mjs'; +import { main as runBriefs } from './seed-regional-briefs.mjs'; + +loadEnvFile(import.meta.url); + +const BRIEF_COOLDOWN_MS = 6.5 * 24 * 60 * 60 * 1000; // 6.5 days +const BRIEF_META_KEY = 'seed-meta:intelligence:regional-briefs'; + +/** + * Check if the weekly brief seeder should run by reading its seed-meta + * timestamp. Returns true when the last run was >6.5 days ago or the + * meta key doesn't exist (first run). + */ +async function shouldRunBriefs() { + try { + const { url, token } = getRedisCredentials(); + const resp = await fetch(`${url}/get/${encodeURIComponent(BRIEF_META_KEY)}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!resp.ok) return true; // Redis error → run defensively + const data = await resp.json(); + if (!data?.result) return true; // key missing → first run + const meta = JSON.parse(data.result); + const lastRun = meta?.fetchedAt ?? 0; + const age = Date.now() - lastRun; + if (age >= BRIEF_COOLDOWN_MS) { + console.log(`[bundle] briefs: last run ${(age / 86_400_000).toFixed(1)} days ago, running`); + return true; + } + console.log(`[bundle] briefs: last run ${(age / 86_400_000).toFixed(1)} days ago, skipping (cooldown ${(BRIEF_COOLDOWN_MS / 86_400_000).toFixed(1)}d)`); + return false; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[bundle] briefs: cooldown check failed (${msg}), running defensively`); + return true; + } +} + +async function main() { + const t0 = Date.now(); + console.log('[bundle] Regional Intelligence seed bundle starting'); + + let snapshotFailed = false; + + // 1. Always run snapshots (6h cadence) + console.log('[bundle] ── Running regional snapshots ──'); + try { + await runSnapshots(); + } catch (err) { + snapshotFailed = true; + const msg = err instanceof Error ? err.message : String(err); + console.error(`[bundle] snapshots failed: ${msg}`); + // Continue to briefs check — but skip briefs if snapshots failed + // so we don't generate a weekly brief from stale data. + } + + // 2. Conditionally run briefs (weekly). SKIP if snapshots failed this + // cycle — the brief reads the :latest snapshot from Redis with no + // freshness check, so running after a snapshot failure would produce a + // brief summarizing stale state and write fresh seed-meta that hides + // the staleness. PR #3001 review M2. + if (!snapshotFailed && await shouldRunBriefs()) { + console.log('[bundle] ── Running weekly briefs ──'); + try { + await runBriefs(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[bundle] briefs failed: ${msg}`); + // Don't exit yet — report failure below. + snapshotFailed = true; // reuse flag for exit code + } + } else if (snapshotFailed) { + console.log('[bundle] ── Skipping weekly briefs (snapshots failed this cycle) ──'); + } + + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + + // Exit non-zero when any seeder failed so Railway cron monitoring can + // detect broken runs. PR #3001 review H1. + if (snapshotFailed) { + console.error(`[bundle] Done in ${elapsed}s with ERRORS`); + process.exit(1); + } + console.log(`[bundle] Done in ${elapsed}s`); +} + +main().catch((err) => { + console.error('[bundle] Fatal:', err); + process.exit(1); +}); diff --git a/scripts/seed-regional-briefs.mjs b/scripts/seed-regional-briefs.mjs index 2cdef3524..74016aed6 100644 --- a/scripts/seed-regional-briefs.mjs +++ b/scripts/seed-regional-briefs.mjs @@ -203,7 +203,7 @@ async function main() { } console.log(`[regional-briefs] Done in ${elapsed}s: generated=${generated} skipped=${skipped} failed=${failed}`); - if (failed > 0) process.exit(1); + if (failed > 0) throw new Error(`regional-briefs: ${failed} region(s) failed`); } const isMain = import.meta.url === pathToFileURL(process.argv[1]).href; diff --git a/scripts/seed-regional-snapshots.mjs b/scripts/seed-regional-snapshots.mjs index 5c62aa55e..9b1b7f657 100644 --- a/scripts/seed-regional-snapshots.mjs +++ b/scripts/seed-regional-snapshots.mjs @@ -320,7 +320,10 @@ async function main() { console.error(` [${f.region}] ${f.error}`); } console.error('[regional-snapshots] Skipping seed-meta write due to partial failure. /api/health will reflect degradation after 12h.'); - process.exit(1); + // Throw instead of process.exit(1) so callers (e.g. seed-bundle-regional.mjs) + // can catch and continue with other seeders. The isDirectRun guard below still + // calls process.exit(1) for standalone invocations. + throw new Error(`regional-snapshots: ${failed} region(s) failed`); } const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;