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)
This commit is contained in:
@@ -51,6 +51,8 @@ const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
// it is a ten-second wait on the dead port.
|
||||
const resourcesDb = require('../src/model/events/eventRunResources.db')
|
||||
const gates = require('../src/events/gates')
|
||||
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||
const engagementEmit = require('../src/utils/engagementEmit')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -63,7 +65,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
|
||||
let store
|
||||
const originals = {}
|
||||
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb]]) {
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb], ['participantsDb', participantsDb], ['engagementEmit', engagementEmit]]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
|
||||
@@ -98,6 +100,33 @@ function installStubs() {
|
||||
// connection timeout.
|
||||
Object.assign(definitionsDb, { findSchedulable: async () => [] })
|
||||
|
||||
// ── The lifecycle announcements (Phase 10) ──
|
||||
//
|
||||
// `events/announce.js` reads the definition on every transition, so left
|
||||
// unstubbed every emit here would wait out the dead-port pool — ten seconds a
|
||||
// transition, on a file whose whole subject is how many ticks a thing takes.
|
||||
// Stubbed rather than silenced: the announce path runs for REAL against these,
|
||||
// which is what lets `emits()` below assert that the wiring is where it should
|
||||
// be. Two runner tests turn on it, and a third would have caught the wiring
|
||||
// being on the losing side of a compare-and-set.
|
||||
definitionsDb.getById = async (id) => ({
|
||||
id,
|
||||
title: `definition ${id}`,
|
||||
summary: null,
|
||||
series_name: null,
|
||||
timezone: 'UTC',
|
||||
})
|
||||
participantsDb.countForRun = async () => 0
|
||||
store.participants = []
|
||||
participantsDb.record = async (row) => {
|
||||
store.participants.push(row)
|
||||
}
|
||||
store.emits = []
|
||||
engagementEmit.emit = (owner, triggerId, envelope) => {
|
||||
store.emits.push({ owner, triggerId, envelope })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── The resource ledger (Phase 8) ──
|
||||
//
|
||||
// `reserve` enforces `uq_evres_target` in the stub, because the refusal it
|
||||
@@ -535,6 +564,7 @@ const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params,
|
||||
const run = (id) => store.runs.get(id)
|
||||
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
|
||||
const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind)
|
||||
const emits = () => store.emits.map((e) => e.triggerId)
|
||||
const gateOf = (id, phase) => store.gates.get(`${id}|${phase}`)
|
||||
|
||||
// A registered test action whose behaviour the test dictates.
|
||||
@@ -658,6 +688,103 @@ test('a run passes through `ending` on its way to completed', async () => {
|
||||
assert.deepEqual(transitions, ['starting', 'running', 'ending', 'completed'])
|
||||
})
|
||||
|
||||
// ── The lifecycle announcements (Phase 10) ─────────────────────────────────
|
||||
//
|
||||
// §J: a run says what happened and an operator's rule decides who is told. What
|
||||
// belongs in THIS file is only that the runner says it at the right moments —
|
||||
// after the guarded transition, once, and not for the phase `run.started`
|
||||
// already covered.
|
||||
|
||||
test('a run announces its lifecycle: started, each LATER phase, ending, completed', async () => {
|
||||
register([scriptedAction('test.noop')])
|
||||
|
||||
const id = seedRun([
|
||||
{ key: 'opening', label: 'Opening', steps: [step('test.noop')] },
|
||||
{ key: 'closing', label: 'Closing', steps: [step('test.noop')] },
|
||||
])
|
||||
await runner.tick(T0)
|
||||
|
||||
// The FIRST phase does not fire `phase.changed`. `run.started` already said the
|
||||
// event began, and a deployment with a rule on each would announce the opening
|
||||
// twice, seconds apart, saying the same thing.
|
||||
assert.deepEqual(emits(), [
|
||||
'event.run.started',
|
||||
'event.phase.changed',
|
||||
'event.run.ending',
|
||||
'event.run.completed',
|
||||
])
|
||||
|
||||
const changed = store.emits.find((e) => e.triggerId === 'event.phase.changed')
|
||||
assert.equal(changed.envelope.data.phase, 'closing')
|
||||
assert.equal(changed.envelope.data.phaseIndex, 2)
|
||||
assert.equal(changed.envelope.data.phaseCount, 2)
|
||||
|
||||
// Keyed on the run, so a weekly event is not throttled by last week's.
|
||||
assert.equal(changed.envelope.subject, String(id))
|
||||
assert.equal(changed.envelope.scopeKey, `event:${id}`)
|
||||
})
|
||||
|
||||
test('a REHEARSAL announces the same things, ceilinged to staff', async () => {
|
||||
// §I. Emitting nothing would be a rehearsal of everything except the
|
||||
// announcements, which are the part most worth rehearsing.
|
||||
register([scriptedAction('test.noop')])
|
||||
|
||||
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }])
|
||||
store.runs.get(id).rehearsal = 1
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.deepEqual(emits(), ['event.run.started', 'event.run.ending', 'event.run.completed'])
|
||||
assert.ok(store.emits.every((e) => e.envelope.ceiling === 'staff'))
|
||||
})
|
||||
|
||||
test('a failed run announces the failure and nothing else', async () => {
|
||||
register([scriptedAction('test.boom')])
|
||||
scripted['test.boom'] = { calls: [], answer: { ok: false, retry: false, error: 'the shard said no' } }
|
||||
|
||||
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.boom', {}, 'abort_run')] }])
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(run(id).status, 'failed')
|
||||
assert.deepEqual(emits(), ['event.run.started', 'event.run.failed'])
|
||||
const failed = store.emits.find((e) => e.triggerId === 'event.run.failed')
|
||||
assert.equal(failed.envelope.data.error, 'the shard said no')
|
||||
assert.equal(failed.envelope.data.runUrl, `/admin/events/runs/${id}`)
|
||||
})
|
||||
|
||||
test('a step reporting participants records them, and a bad one does not fail the step', async () => {
|
||||
register([scriptedAction('test.collect')])
|
||||
scripted['test.collect'] = {
|
||||
calls: [],
|
||||
answer: {
|
||||
ok: true,
|
||||
participants: [{ memberKey: 'darrow', score: 12, userId: 4 }, { score: 3 }],
|
||||
},
|
||||
}
|
||||
|
||||
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.collect')] }])
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
assert.deepEqual(store.participants.map((p) => p.memberKey), ['darrow'])
|
||||
assert.equal(store.participants[0].runId, id)
|
||||
|
||||
const line = store.log.find((l) => l.runId === id && l.kind === 'participants.recorded')
|
||||
assert.equal(line.detail.recorded, 1)
|
||||
assert.match(line.detail.rejected.join(' '), /bad memberKey/)
|
||||
})
|
||||
|
||||
test('a run that completes counts the participants it recorded', async () => {
|
||||
register([scriptedAction('test.collect')])
|
||||
scripted['test.collect'] = { calls: [], answer: { ok: true, participants: [{ memberKey: 'darrow' }] } }
|
||||
participantsDb.countForRun = async () => 1
|
||||
|
||||
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.collect')] }])
|
||||
await runner.tick(T0)
|
||||
|
||||
const completed = store.emits.find((e) => e.triggerId === 'event.run.completed')
|
||||
assert.equal(completed.envelope.data.participantCount, 1)
|
||||
})
|
||||
|
||||
test('phases run in order and the next one is materialised on entry', async () => {
|
||||
register([scriptedAction('test.noop')])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user