Files
website/server/src/model/announceJobs/announceJobs.model.js
wtclaude 7d3d6d5abd
Some checks failed
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Failing after 5m47s
PR Checks / bot-tests (pull_request) Successful in 8m27s
feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who
took part, publishes a results table, and announces a post through the legs the
news pipeline already uses. Events owns none of the delivery: a run says what
happened and an operator's rule decides who is told, so email, the in-app inbox,
push tickles, Discord and the town crier all arrive without anything in
`events/` growing a second delivery path.

**No route was added and nothing moved.** The whole surface is two more derived
fields on a run — `participants` and `resultsPublishedAt` — and a zero-line
`routes.manifest.json` diff proves it.

Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly
where `news.post` sits, and `run.failed` at `admin` on both halves. Every one
keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot
key so a deployment that has already stamped the Team and news keys still gets
them.

**The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a
rehearsal runs for real "with announcements ceilinged to `staff`" — but a
ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as
the real thing, so the moment this phase gave a run something to announce,
rehearsing a published event would have mailed every subscriber. The emit
envelope now takes an optional `ceiling` and the send-time G24 gate applies
`meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse
every rule rather than resolving to either.

`MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so
1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third
time.

Three defects the live walk found, none visible to a unit test:

1. **A channel that reported success while reaching nobody.** The seeded
   `run.started` rule named `push`, because §8.5 and the plan both do. Push
   delivery joins `notification_subscriptions`, only ever written for an id the
   preferences screen offered push for — and it offers push only for a
   registered STREAM. So the tickle went nowhere every time while
   `pushChannel.deliver` answered "tickle published". `event.run.started` is now
   a stream as well as a trigger; the other six are not.
2. **A trigger's `description` reaches a recipient.** It is the structural
   projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing."
   put those words in an administrator's own inbox item.
3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The
   connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have
   counted every idempotent retried collect as a fresh participant.

And one caught before it shipped: ranking with a session variable is wrong here,
because `query()` takes a pool connection per call — the variable would be set
on one connection and read on another. A window function needs no session state.

## Verification

- `npm test --prefix server` — **1981 pass, 1 fail**, and that one
  (`botScore.test.js`) passes standalone at 18/18: a file-level flake under
  parallel load. Run with an empty `MODULES_DIR`, as CI does.
- `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green.
- Zero-line `routes.manifest.json` / `routes.guards.json` diff.
- A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail
  arrived, headed with the event's title and its start time in the shard's own
  zone; the rehearsal fired the same trigger and produced zero outbox rows where
  the real run produced three; `run.failed` reached the administrator's inbox
  and no player's; `core.announce.post` queued a second job without touching the
  news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert
  were run against real MariaDB 11.

## One thing for a reviewer, out of scope and not fixed

**Every `#swagger.description` in this repo is truncated in the generated spec.**
swagger-autogen does not honour a backslash-escaped apostrophe, so a description
is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is
pre-existing and repo-wide. Only the one annotation this phase edits is fixed
here (a typographic apostrophe), because otherwise this phase's own addition to
it would be dead text. The rest wants its own change.

- [x] AI-assisted: Claude Code (Opus 5).

Docs: RunicGateway/docs#TBD.

Co-Authored-By: Claude <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-04 13:06:46 -05:00

171 lines
7.6 KiB
JavaScript

// ── Announcement pipeline: orchestration ────────────────────────────────────
//
// Sits between the DB rows and the worker: creates jobs on publish, records each
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
// announced_at when every leg lands, and resets a leg for the admin retry button.
// The pure decisions (backoff, rollup) live in .logic.js; which legs exist at all
// is modules/registries.js's answer, not this file's (MODULE_SYSTEM.md §1.8).
const db = require('./announceJobs.db')
const logic = require('./announceJobs.logic')
const registries = require('../../modules/registries')
const posts = require('../posts/posts.model')
const log = require('../../utils/logger')('announce')
// Enqueue an announcement for a freshly-published news post: one job row, one leg
// row per registered leg (all pending, due immediately), plus a back-pointer on
// the post so the admin panel can find it. Returns the new job id.
async function enqueue(postId) {
const jobId = await db.create(postId, registries.announceLegIds())
await posts.linkAnnounceJob(postId, jobId)
log.info('announce job enqueued', { jobId, postId })
return jobId
}
// Should publishing this post fire the pipeline? Only on a real transition INTO
// "published news" — a false→true publish while in news, or a category change
// into news while already published — and never twice (guarded by the post's
// existing announce_job_id). Editing an already-announced post does not re-fire.
function shouldEnqueue(post, { wasPublished, wasNews }) {
if (!post || post.category !== 'news' || !post.published) return false
if (post.announce_job_id) return false
const wasNewsPublished = Boolean(wasPublished) && Boolean(wasNews)
return !wasNewsPublished
}
// Convenience used by the post controller: enqueue iff shouldEnqueue. Never
// throws — a pipeline hiccup must not break saving/publishing a post.
async function enqueueIfNeeded(post, transition) {
try {
if (!shouldEnqueue(post, transition)) return null
return await enqueue(post.id)
} catch (err) {
log.error('enqueueIfNeeded failed', { postId: post && post.id, message: err.message })
return null
}
}
/**
* Enqueue an announcement a RUN asked for (EVENTS.md §J, Phase 10).
*
* The same job, the same legs, the same worker — so the town crier and Discord
* come free, with their retry and their classification, rather than an event
* growing a second delivery pipeline that would need both again and get them
* subtly wrong. Two things differ, and both are about not standing on the news
* pipeline's toes:
*
* **The post's back-pointer is written only when it has none.** `announce_job_id`
* is what the post admin panel reads and what `shouldEnqueue` guards on, so
* moving it to an event's job would make a re-published post announce itself
* again. A post that has never been announced gains the pointer, because then
* this job IS its announcement and the panel should show it.
*
* **`announced_at` is not stamped by a run's job** — see `refreshStatus`.
*
* Returns the new job id.
*/
async function enqueueForRun(postId, runId) {
const jobId = await db.create(postId, registries.announceLegIds(), { runId })
const post = await posts.getById(postId)
if (post && !post.announce_job_id) await posts.linkAnnounceJob(postId, jobId)
log.info('announce job enqueued for a run', { jobId, postId, runId })
return jobId
}
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
// attempt count and schedule the next run (or fail the leg once the cap is hit).
// Returns the updated job row.
async function recordOutcome(job, leg, { outcome, error }) {
const row = (job.legs || []).find((l) => l.leg === leg)
const attempts = Number(row && row.attempts) || 0
if (outcome === 'done') {
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
} else if (outcome === 'terminal') {
await db.updateLeg(job.id, leg, { status: 'failed', attempts: attempts + 1, lastError: error, nextAttemptAt: null })
log.warn('announce leg failed (terminal)', { jobId: job.id, leg, error })
} else {
const nextAttempts = attempts + 1
const delay = logic.scheduleAfter(nextAttempts)
if (delay === null) {
await db.updateLeg(job.id, leg, { status: 'failed', attempts: nextAttempts, lastError: error, nextAttemptAt: null })
log.warn('announce leg failed (retries exhausted)', { jobId: job.id, leg, attempts: nextAttempts, error })
} else {
const nextAttemptAt = new Date(Date.now() + delay)
await db.updateLeg(job.id, leg, { status: 'pending', attempts: nextAttempts, lastError: error, nextAttemptAt })
log.info('announce leg retry scheduled', { jobId: job.id, leg, attempts: nextAttempts, nextAttemptAt })
}
}
return refreshStatus(job.id)
}
// Recompute and persist the parent status from the legs; stamp the post's
// announced_at the moment every leg has delivered.
async function refreshStatus(jobId) {
const job = await db.findById(jobId)
if (!job) return null
const status = logic.rollupStatus(job.legs.map((l) => l.status))
if (status !== job.status) await db.setStatus(jobId, status)
job.status = status
// **A run's job does not stamp the post** (Phase 10). `announced_at` means
// "when this post was announced", and an event that links a three-week-old
// news article would otherwise rewrite that to today — making the post admin
// panel report a publication date it does not have. The event's own record of
// having announced is the run log line and the job's `run_id`.
if (status === 'done' && !job.run_id) {
try {
await posts.markAnnounced(job.post_id)
} catch (err) {
log.warn('markAnnounced failed', { jobId, postId: job.post_id, message: err.message })
}
}
return job
}
// Admin retry button: reset one leg to pending, clear its error/backoff, and let
// the worker pick it up on the next tick. Resets the attempt count so a retry
// after a config fix gets a full budget again.
async function resetLeg(postId, leg) {
if (!registries.announceLeg(leg)) throw new Error(`unknown announce leg: ${leg}`)
const job = await db.findByPostId(postId)
if (!job) return null
// A job enqueued before this leg was registered has no row for it; create it so
// the retry button works on an existing post after a module is installed.
await db.ensureLegs(job.id, [leg])
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
// Labelled, because this is the response body the admin panel re-renders from.
return withLabels(await refreshStatus(job.id))
}
// Decorate a job's legs with the label their registration carries, so the admin
// panel renders a module's leg with a real name and no client change
// (MODULE_SYSTEM.md §1.8). An unregistered leg — a stale row from a module that
// was since removed — keeps its id as the label rather than disappearing.
function withLabels(job) {
if (!job) return job
job.legs = (job.legs || []).map((l) => {
const registered = registries.announceLeg(l.leg)
return { ...l, label: registered ? registered.label : l.leg }
})
return job
}
async function getByPostId(postId) {
return withLabels(await db.findByPostId(postId))
}
module.exports = {
enqueue,
enqueueForRun,
shouldEnqueue,
enqueueIfNeeded,
recordOutcome,
refreshStatus,
resetLeg,
getByPostId,
withLabels,
}