feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
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

`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)
This commit is contained in:
2026-09-04 13:06:46 -05:00
parent d4516739b4
commit 7d3d6d5abd
36 changed files with 2960 additions and 39 deletions

View File

@@ -11,7 +11,7 @@
const { query } = require('../../utils/db')
const COLS = 'id, post_id, status, created_at, updated_at'
const COLS = 'id, post_id, run_id, status, created_at, updated_at'
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
async function legsFor(jobIds) {
@@ -34,8 +34,15 @@ async function attachLegs(jobs) {
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
// an empty list is legal and yields a job with nothing to deliver.
async function create(postId, legs = []) {
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
//
// `runId` is Phase 10's (EVENTS.md §J): a job an EVENT asked for, rather than
// the one a news publish enqueues. It changes nothing about how the job is
// dispatched, retried or rolled up — the whole point of reusing this pipeline is
// that an event announcement gets the legs, the backoff and the classification
// already written — and everything it does change is in the two places below
// that ask "whose job is this".
async function create(postId, legs = [], { runId = null } = {}) {
const res = await query('INSERT INTO announce_jobs (post_id, run_id) VALUES (?, ?)', [postId, runId])
const jobId = Number(res.insertId)
if (legs.length > 0) {
const values = legs.map(() => '(?, ?)').join(', ')
@@ -65,9 +72,15 @@ async function findById(id) {
return (await attachLegs(rows))[0]
}
// **The post's OWN job, which is what `run_id IS NULL` means here.** A post may
// now have more than one — the news publish enqueued one, and an event linked the
// same post later — and every caller of this function is the post admin panel or
// its retry button, which are about the news announcement. Without the clause the
// panel would silently start rendering an event's job the moment one existed, and
// the retry button would retry that instead.
async function findByPostId(postId) {
const rows = await query(
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? AND run_id IS NULL ORDER BY id DESC LIMIT 1`,
[postId],
)
if (rows.length === 0) return null

View File

@@ -45,6 +45,33 @@ async function enqueueIfNeeded(post, transition) {
}
}
/**
* 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).
@@ -82,7 +109,12 @@ async function refreshStatus(jobId) {
const status = logic.rollupStatus(job.legs.map((l) => l.status))
if (status !== job.status) await db.setStatus(jobId, status)
job.status = status
if (status === 'done') {
// **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) {
@@ -127,6 +159,7 @@ async function getByPostId(postId) {
module.exports = {
enqueue,
enqueueForRun,
shouldEnqueue,
enqueueIfNeeded,
recordOutcome,

View File

@@ -39,6 +39,7 @@
const runsDb = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const announce = require('../../events/announce')
const gatesDb = require('./eventPhaseGates.db')
const resourcesDb = require('./eventRunResources.db')
const gates = require('../../events/gates')
@@ -195,6 +196,16 @@ async function cancel(runId, { reason, cleanup = true } = {}, userId = null, { i
},
})
// **After the guarded transition, so exactly one caller announces** (Phase
// 10). Two moderators pressing cancel in the same second both reach the log
// write; only one of them wins `transition`, and the loser has already
// returned a 409 above.
//
// The operator's `reason`, not the run's `last_error` — `cancel` takes a
// sentence a human typed for other humans, and the diagnostic string that
// ends up in `last_error` would read as gibberish in a mail.
await announce.runCancelled(run, note)
// **The teardown is not done here, and the request does not wait for it.**
// Cleanup is one leg of the runner's tick over terminal runs (§L), which is
// what makes it survive a process that dies halfway through it — and a cancel

View File

@@ -57,6 +57,14 @@ const KINDS = [
'cleanup.failed', // a group did not, with the reason and how it was left
'cleanup.swept', // one pass over a run's ledger, and what it found
'cleanup.retry', // a human cleared the attempt counter and asked again
// Phase 10's four: the integrations. `announcement.emitted` is a line about
// what the run SAID happened, not about who was told -- the engagement engine
// owns that decision and logs its own, and a run log that claimed to know how
// many mails went out would be reporting a decision it does not make.
'participants.recorded', // a step reported who took part, and they are recorded
'results.published', // the results table was ranked and stamped
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
'announcement.enqueued', // a post was linked to this run and queued on the legs
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }

View File

@@ -0,0 +1,119 @@
// ── event_run_participants — SQL only ──────────────────────────────────────
//
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of
// §D's core tables: who took part in a run, and how well.
//
// **Core writes this table and never sources it.** A `member_key` is
// module-opaque, exactly as a resource's `ref` is — core cannot map a character
// name onto a user row and must not try, because that mapping is one game's
// (`shard_links`, for module-uo) and would be that game compiled into core. A
// module that knows both halves reports both; core stores what it is told.
//
// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step
// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried
// collect that duplicated its rows would double a leaderboard. It is the same
// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table
// along, with the difference that a re-report may carry a BETTER score and must
// win rather than be ignored.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta,
created_at, updated_at`
// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already
// arrives as a JS number; the coercion is belt to that braces and costs nothing.
// `meta` is hydrated for the reason a resource's payload is: opaque to core, but
// every caller wants the object rather than the string the driver returns.
const hydrate = (row) =>
row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) }
/**
* Record one participant, or update the one already recorded.
*
* **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is
* the point of the column: it is when this participant first appeared, and a
* second report — a later collect, a corrected score — must not rewrite it. The
* same applies to `rank_at`, which is not touched here at all: ranking is
* `core.results.publish`'s job and a re-report between two publications must not
* silently invent a rank nobody computed.
*
* `user_id` DOES move on a re-report, deliberately: a player who linked their
* website account between two collects should stop being anonymous, and the
* module is the only thing that can know they did.
*
* **It answers nothing, and the reason is a trap worth naming.** The obvious
* return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an
* update. That is true only without `CLIENT_FOUND_ROWS`, and this connector
* sends it: with it, a re-report whose values are identical also answers 1, so
* the flag would report every idempotent retry as a fresh participant. The
* caller wants "how many were reported" anyway, which it already knows from the
* length of its own list.
*/
async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) {
await query(
`INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at)
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
score = VALUES(score),
meta = VALUES(meta)`,
[runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt],
)
}
/** One run's participants, best first. The results table, and the console's. */
async function listForRun(runId, limit = 500) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_participants
WHERE run_id = ?
ORDER BY score DESC, joined_at ASC, id ASC
LIMIT ?`,
[runId, limit],
)
return rows.map(hydrate)
}
/** How many the run has. Its own query because the trigger payload needs only this. */
async function countForRun(runId) {
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
return Number(rows[0]?.n || 0)
}
/**
* Number every participant of one run by score, best first.
*
* **One statement, and it has to be one.** The obvious form — `SET @rk := 0`
* followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is
* wrong here in a way that would have passed every test that did not run twice
* concurrently: `query()` takes a connection from the pool per call and releases
* it, so the session variable is set on one connection and read on whichever the
* second call happens to get. A window function needs no session state at all.
*
* **The ordering is total.** `score DESC` alone leaves ties in whatever order the
* engine felt like, so two publications of the same run would hand out different
* ranks to the same two people; `joined_at` then `id` breaks every tie the same
* way every time, which is what makes re-publishing idempotent rather than a
* reshuffle.
*
* Ties share nothing — two people on the same score get consecutive ranks rather
* than a dense or competition ranking. That is a presentation decision belonging
* to whatever renders the table; what this owes is a stable number.
*/
async function rankRun(runId) {
const result = await query(
`UPDATE event_run_participants p
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk
FROM event_run_participants
WHERE run_id = ?) r ON r.id = p.id
SET p.rank_at = r.rk`,
[runId],
)
// The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather
// than rows changed — which is the number wanted here. Re-publishing a run
// whose ranks are already correct answers "12 ranked", not "0".
return Number(result.affectedRows || 0)
}
module.exports = { record, listForRun, countForRun, rankRun }

View File

@@ -412,6 +412,20 @@ async function setCleanupStatus(id, to, from = null) {
return Number(result?.affectedRows || 0) === 1
}
/**
* Stamp this run's results table as published (EVENTS.md §J, Phase 10).
*
* **Unguarded, and re-stampable.** `core.results.publish` is an ordinary step
* that an author may place more than once — before an announcement and again
* after a late correction — and each publication is a real one whose moment is
* worth recording. Guarding it on `IS NULL` would make the second silently do
* nothing while the ranking beside it did move, which is the worst of both.
*/
async function markResultsPublished(id, at = new Date()) {
const result = await query('UPDATE event_runs SET results_published_at = ? WHERE id = ?', [at, id])
return Number(result?.affectedRows || 0) === 1
}
/**
* Runs whose start instant passed more than their own grace window ago (§E, §L).
*
@@ -515,6 +529,7 @@ module.exports = {
transition,
setHealth,
setCleanupStatus,
markResultsPublished,
concurrencyHolder,
reclaimStale,
terminalBefore,

View File

@@ -28,6 +28,7 @@ const versionsDb = require('./eventVersions.db')
const settingsDb = require('./eventActionSettings.db')
const budgetDb = require('./eventRunBudget.db')
const resourcesDb = require('./eventRunResources.db')
const participantsDb = require('./eventRunParticipants.db')
const authorize = require('../../events/authorize')
const MAX_SCOPE = 190
@@ -205,12 +206,13 @@ async function create(
async function detail(runId) {
const run = await db.getById(runId)
if (!run) return null
const [steps, counts, gateRows, budget, resources] = await Promise.all([
const [steps, counts, gateRows, budget, resources, attendees] = await Promise.all([
stepsDb.listForRun(runId),
stepsDb.statusCounts(runId),
gatesDb.listForRun(runId),
budgetDb.forRun(runId),
resourcesDb.forRun(runId),
participantsDb.listForRun(runId),
])
const now = new Date()
return {
@@ -259,6 +261,27 @@ async function detail(runId) {
// than over the list above — a placeholder left standing by a lost
// acknowledgement is exactly the case `cleanup_status` must not call clean.
unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length,
// Who took part, best first (Phase 10). Returned on every run rather than
// only on a published one: the console's question is "what did this event
// record", and a run whose module has collected but whose author never
// placed a publish step is exactly the case an operator needs to see. What
// `results_published_at` on the run row then says is whether anyone OUTSIDE
// this screen may read it — which is Phase 14's question, not this one's.
//
// **`rank` is `rank_at`, renamed at the boundary and not in the column.**
// `rank` is a reserved word in MariaDB 10.2+ (it is the window function),
// so the column carries the suffix and the API carries the name a client
// wants. The alternative — backticking the column at every use — is one
// forgotten pair of backticks away from a syntax error in a query nobody
// runs until a run completes at four in the morning.
participants: attendees.map((p) => ({
memberKey: p.member_key,
userId: p.user_id,
score: p.score,
rank: p.rank_at,
joinedAt: p.joined_at,
meta: p.meta,
})),
}
}

View File

@@ -12,6 +12,19 @@ async function listPublished(category) {
)
}
// Every published post, across categories, newest first — the option source
// behind `core.announce.post`'s `postId` param (EVENTS.md §F, Phase 10). Its own
// query rather than a loop over `listPublished` because an authoring dropdown
// wants one bounded, ordered list and needs neither the body nor the excerpt: a
// hundred posts' bodies would be a megabyte of HTML sent to draw a `<select>`.
async function listPublishedForOptions(limit = 200) {
const n = Math.min(Math.max(Number(limit) || 200, 1), 500)
return query(
'SELECT id, category, title FROM posts WHERE published = 1 ' +
`ORDER BY COALESCE(published_at, created_at) DESC, id DESC LIMIT ${n}`,
)
}
// All posts for a category (admin), newest first.
async function listAll(category) {
if (category) {
@@ -74,6 +87,7 @@ async function countByCategory() {
module.exports = {
listPublished,
listPublishedForOptions,
listAll,
findById,
findPublished,