// ── Announcement pipeline: pure logic ────────────────────────────────────── // // No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make, // kept here so they are unit-testable in isolation (server/test/announceJobs.test.js): // • scheduleAfter — exponential backoff schedule + the attempt cap // • rollupStatus — derive the parent job status from its legs // • legError — squeeze a client result into one error line // • baseUrl / articleUrl — the public link an announcement carries // // What used to be here and is not any more: `buildTownCrierText`, // `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result // classification belong to the leg, and a leg is a registration now // (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and // utils/discordAnnounce.js. This file is what every leg shares. // Backoff between retries, indexed by attempts-so-far. Six attempts spread over // ~a couple of hours; after the last one a leg is marked failed and surfaced in // the post's admin panel. Shared by every leg. const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000] const MAX_ATTEMPTS = BACKOFF_MS.length // The site's public base, used to build the link an announcement carries. function baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } // The public link that goes in the announcement. News has no per-post route // (App.jsx only has the /site/news list), so we link the list — matches the // pre-pipeline Discord announce behavior. function articleUrl(base) { return `${String(base || '').replace(/\/+$/, '')}/site/news` } // Every leg's client returns { ok, status, data, error }. Squeeze a failure into // the one line stored in announce_job_legs.last_error and shown in the panel. function legError(result) { if (!result) return 'no response' if (result.status) { return result.data && result.data.message ? `${result.status}: ${result.data.message}` : result.error || `status ${result.status}` } return result.error || 'request failed' } // Given the number of attempts already made (>= 1), how long to wait before the // next one — or null when the cap is reached and the leg should be failed. function scheduleAfter(attempts) { if (attempts >= MAX_ATTEMPTS) return null return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] } // Parent job status derived from its leg statuses: // done — every leg delivered // failed — every leg gave up // partial — at least one leg reached a terminal state without all of them // agreeing (some still pending/retrying, or a mix of done and failed) // pending — no leg is terminal yet // // Takes the list of leg statuses rather than two named arguments, because the leg // set is registered rather than fixed (MODULE_SYSTEM.md §1.8). No legs at all // rolls up `done`: with nothing registered there is nothing left to deliver, and // leaving such jobs `pending` would pile up rows the worker never touches. function rollupStatus(statuses) { const list = Array.isArray(statuses) ? statuses : [] const terminal = (s) => s === 'done' || s === 'failed' if (list.every((s) => s === 'done')) return 'done' if (list.every((s) => s === 'failed')) return 'failed' if (list.some(terminal)) return 'partial' return 'pending' } module.exports = { MAX_ATTEMPTS, BACKOFF_MS, baseUrl, articleUrl, legError, scheduleAfter, rollupStatus, }