From 7d3d6d5abde3739894bef16a70ee2379a5f56ee8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 13:06:46 -0500 Subject: [PATCH] =?UTF-8?q?feat(events):=20the=20integrations=20=E2=80=94?= =?UTF-8?q?=20lifecycle=20triggers,=20participants,=20results=20(Phase=201?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- client/src/routes/admin/views/EventRun.jsx | 67 ++++ server/db/schema.sql | 80 +++++ server/engagement-triggers.json | 336 ++++++++++++++++++ server/src/config/coreEventActions.js | 168 +++++++++ server/src/config/coreStreams.js | 32 ++ server/src/config/coreTriggers.js | 226 ++++++++++++ server/src/engagement/audiences.js | 20 +- server/src/engagement/coreRules.js | 91 ++++- server/src/engagement/engine.js | 12 +- server/src/engagement/templateSeeds.js | 50 +++ server/src/events/announce.js | 225 ++++++++++++ server/src/events/dispatch.js | 22 +- server/src/events/participants.js | 162 +++++++++ .../src/model/announceJobs/announceJobs.db.js | 21 +- .../model/announceJobs/announceJobs.model.js | 35 +- .../model/events/eventRunControls.model.js | 11 + server/src/model/events/eventRunLog.db.js | 8 + .../model/events/eventRunParticipants.db.js | 119 +++++++ server/src/model/events/eventRuns.db.js | 15 + server/src/model/events/eventRuns.model.js | 25 +- server/src/model/posts/posts.db.js | 14 + .../src/router/v1/admin/events.controller.js | 9 + server/src/router/v1/admin/events.router.js | 4 +- server/src/utils/engagementEmit.js | 31 +- server/src/utils/eventRunner.js | 67 +++- server/swagger/swagger-output.json | 15 +- server/test/engagementEngine.test.js | 42 +++ server/test/engagementTriggers.test.js | 42 ++- server/test/eventActionRegistry.test.js | 23 +- server/test/eventAnnounce.test.js | 308 ++++++++++++++++ server/test/eventIntegrations.test.js | 311 ++++++++++++++++ server/test/eventParticipants.test.js | 193 ++++++++++ server/test/eventRunControls.test.js | 45 ++- server/test/eventRunner.test.js | 129 ++++++- server/test/eventsAdmin.test.js | 30 +- server/test/moduleRegistries.test.js | 11 + 36 files changed, 2960 insertions(+), 39 deletions(-) create mode 100644 server/src/events/announce.js create mode 100644 server/src/events/participants.js create mode 100644 server/src/model/events/eventRunParticipants.db.js create mode 100644 server/test/eventAnnounce.test.js create mode 100644 server/test/eventIntegrations.test.js create mode 100644 server/test/eventParticipants.test.js diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx index e3ff02e..78398f6 100644 --- a/client/src/routes/admin/views/EventRun.jsx +++ b/client/src/routes/admin/views/EventRun.jsx @@ -93,6 +93,12 @@ const STEP_COLOR = { const when = (v) => (v ? new Date(v).toLocaleString() : '—') const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '') +// How many participants the console renders before it stops and counts the rest. +// A run's participants are people and a busy event has hundreds; this panel is a +// check that the collection worked and that the ranking looks right, not the +// results page — that is Phase 14's, and it is public. +const PARTICIPANTS_SHOWN = 50 + /** * Seconds as an operator reads them — the same vocabulary the spec authors a * gate in, so "28 min" on this screen and `after: '30m'` in the editor are @@ -183,6 +189,9 @@ export default function EventRun() { // What this run created or borrowed, and what became of each (Phase 8). const [resources, setResources] = useState([]) const [unresolved, setUnresolved] = useState(0) + // Who took part, best first (Phase 10). Present whether or not the results + // have been published; `run.resultsPublishedAt` is what says which. + const [participants, setParticipants] = useState([]) const [lines, setLines] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -205,6 +214,7 @@ export default function EventRun() { setBudget(detail.budget || []) setResources(detail.resources || []) setUnresolved(detail.unresolvedResources || 0) + setParticipants(detail.participants || []) setLines(log.log || []) }, [runId]) @@ -522,6 +532,63 @@ export default function EventRun() { )} + {/* ── Who took part (Phase 10) ── + Shown whenever a module has reported anybody, published or not — and the + difference between the two is the whole point of the line under the + heading. A run whose participants are collected and unranked is a real + state, not an error: an author has not placed a `core.results.publish` + step, or has not run it yet. Saying "not published yet" is what stops + somebody reading this table as the final standings. */} + {participants.length > 0 && ( +
+

+ Who took part +

+

+ {run.resultsPublishedAt ? ( + <>Results published {clock(run.resultsPublishedAt)}. Ranked best first. + ) : ( + <> + {participants.length} recorded, and the results have not been published — nothing + outside this page shows them, and nobody has a rank yet. Publishing is a{' '} + core.results.publish step in the event + itself. + + )} +

+ + + {participants.slice(0, PARTICIPANTS_SHOWN).map((p) => ( + + + + {/* A participant with no `userId` is not a defect: it is + somebody who turned up without a linked website account, + and the module is the only thing that could have known + otherwise. Saying so beats a blank cell. */} + + + + + ))} + +
+ {p.rank ?? ''} + + {p.memberKey} + + {p.userId ? `account ${p.userId}` : 'no linked account'} + {p.score} + {clock(p.joinedAt)} +
+ {participants.length > PARTICIPANTS_SHOWN && ( +

+ and {participants.length - PARTICIPANTS_SHOWN} more. +

+ )} +
+ )} + {/* ── Waiting on a person ── */} {parked.length > 0 && (
diff --git a/server/db/schema.sql b/server/db/schema.sql index 76744bb..805f4bb 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -2597,3 +2597,83 @@ CREATE TABLE IF NOT EXISTS event_run_resources ( -- start is watching, and that human is the review the gate exists to require. ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL; ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL; + +-- ── Integrations: participants, results and the run's announcements +-- (EVENTS.md §D/§J — Phase 10) ───────────────────────────────────────────── + +-- Who took part, and how well. The eleventh and last of §D's core tables. +-- +-- **Core writes this table and never sources it.** A `member_key` is +-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of +-- Britain" onto a user row and must not try, because the mapping is one game's +-- (`shard_links`, for module-uo) and would be compiled into core the moment it +-- guessed. A module that knows both halves supplies both — `memberKey` always, +-- `userId` when its own link table has one — and core stores what it is told. +-- +-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what +-- happened at an event has to survive the deletion of an account that attended +-- it, or the results of last year's invasion silently rewrite themselves. +-- +-- **`rank` is NULL until results are published** and is computed then, by +-- `core.results.publish`, over `score DESC`. It is a stored column rather than a +-- window function in the read because a published result is a fact about a +-- moment: a participant added afterwards (a late correction, a module's second +-- collect step) must not silently renumber a table people have already read. +CREATE TABLE IF NOT EXISTS event_run_participants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + -- Module-opaque and NOT NULL: it is the identity the module knows, and the + -- half of the unique key that makes a repeated collect idempotent. A run whose + -- module cannot name its participants has no rows here at all. + member_key VARCHAR(190) NOT NULL, + user_id INT NULL, + -- Signed, because a game may score downward as readily as upward, and DECIMAL + -- rather than a float so two equal scores compare equal and a rank is stable. + score DECIMAL(18,4) NOT NULL DEFAULT 0, + rank_at INT NULL, + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Module-opaque. Whatever the module wants results to be able to display + -- beside a name -- a class, a city, a kill count -- with no core vocabulary in + -- it and nothing core ever reads. + meta JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + -- One row per participant per run. What makes a module reporting the same set + -- twice -- a retried collect step, a second attempt after a timeout -- an + -- upsert rather than a duplicated leaderboard. + UNIQUE KEY uq_evpart_member (run_id, member_key), + -- The results table: one run, best first. + INDEX idx_evpart_score (run_id, score), + -- Profile history (`GET /player/events/history`, Phase 14), newest first. + INDEX idx_evpart_user (user_id, joined_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- When the results table was published, and by which run of the publish action. +-- +-- A stamp rather than a status: a run either has published results or it has +-- not, and the two questions a surface asks -- "may I show this table" and "when +-- was it settled" -- are the same column. `core.results.publish` is idempotent +-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an +-- ordinary retried step. +ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL; + +-- The run an announce job belongs to, when it belongs to one. +-- +-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are +-- not an event's, and nothing about how they are enqueued, retried or rolled up +-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job +-- for a post that has already been announced -- the common case, since the post +-- an event announces is very often the news post that announced it -- without +-- either colliding with the first or overwriting `posts.announce_job_id`, which +-- is the back-pointer the post admin panel's retry button reads. +-- +-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce +-- job that went out is a delivery record and must outlive whatever asked for it, +-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a +-- constraint here would be the one statement in this file that cannot replay. +-- The column is read only to answer "which run announced this", and a run id +-- that no longer resolves answers that honestly. +ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL; +ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id); diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index bfa13b9..4c0ab06 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -2,6 +2,342 @@ "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", "moduleApiVersion": "1.10.0", "triggers": [ + { + "id": "event.phase.changed", + "owner": "core", + "label": "Event — a new phase", + "description": "An event that is under way has moved on to its next stage.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "phase", + "type": "string", + "required": true, + "example": "assault", + "description": "The phase key just entered, as authored in the spec." + }, + { + "name": "phaseLabel", + "type": "string", + "required": false, + "example": "The assault", + "description": "The phase label, when the spec gave it one. Falls back to the key." + }, + { + "name": "phaseIndex", + "type": "int", + "required": true, + "example": 2, + "description": "Which phase this is, counting from 1." + }, + { + "name": "phaseCount", + "type": "int", + "required": true, + "example": 4, + "description": "How many phases the pinned version has in total." + } + ] + }, + { + "id": "event.run.cancelled", + "owner": "core", + "label": "Event — cancelled", + "description": "A scheduled event was cancelled by a member of staff.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "reason", + "type": "string", + "required": false, + "example": "The shard is down for an emergency patch.", + "description": "What the staff member gave as the reason, when they gave one." + } + ] + }, + { + "id": "event.run.completed", + "owner": "core", + "label": "Event — finished", + "description": "An event has finished.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "summary", + "type": "string", + "required": false, + "example": "Orcish warbands are massing north of Yew.", + "description": "The event summary, as authored." + }, + { + "name": "participantCount", + "type": "int", + "required": true, + "example": 47, + "description": "How many participants the run recorded. Zero when nothing collected any." + }, + { + "name": "durationMinutes", + "type": "int", + "required": true, + "example": 95, + "description": "How long the run took, start to end, in whole minutes." + } + ] + }, + { + "id": "event.run.ending", + "owner": "core", + "label": "Event — winding down", + "description": "An event is drawing to a close.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + } + ] + }, + { + "id": "event.run.failed", + "owner": "core", + "label": "Event — run failed", + "description": "An event stopped before it finished.", + "kind": "event", + "subjectKey": "runId", + "audience": "admin", + "ceiling": "admin", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "phase", + "type": "string", + "required": false, + "example": "assault", + "description": "The phase it failed in, when it had entered one." + }, + { + "name": "error", + "type": "string", + "required": false, + "example": "sidecar responded 503", + "description": "The run’s last error, verbatim from the run row." + }, + { + "name": "runUrl", + "type": "url", + "required": true, + "example": "/admin/events/runs/3692", + "description": "Site-relative path to the run console." + } + ] + }, + { + "id": "event.run.scheduled", + "owner": "core", + "label": "Event — scheduled", + "description": "A new event has been added to the calendar.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "summary", + "type": "string", + "required": false, + "example": "Orcish warbands are massing north of Yew.", + "description": "The event summary, as authored." + }, + { + "name": "seriesName", + "type": "string", + "required": false, + "example": "The Yew Campaign", + "description": "The arc this event belongs to, when it belongs to one." + }, + { + "name": "startsAt", + "type": "datetime", + "required": true, + "example": "2026-09-12T20:00:00.000Z", + "description": "When the occurrence is due to start, UTC." + }, + { + "name": "timezone", + "type": "string", + "required": false, + "example": "America/New_York", + "description": "The shard-local zone the schedule was authored in." + }, + { + "name": "startsAtLabel", + "type": "string", + "required": false, + "example": "Saturday 12 September at 8:00 pm (America/New_York)", + "description": "The start time written out in the shard-local zone, for a mail to read." + } + ] + }, + { + "id": "event.run.started", + "owner": "core", + "label": "Event — starting now", + "description": "A scheduled event has begun.", + "kind": "event", + "subjectKey": "runId", + "audience": "subscribers", + "ceiling": "authenticated", + "version": 1, + "variables": [ + { + "name": "runId", + "type": "string", + "required": true, + "example": "3692", + "description": "The run this is about. Also the cooldown subject." + }, + { + "name": "title", + "type": "string", + "required": true, + "example": "The Yew Invasion", + "description": "The event title." + }, + { + "name": "summary", + "type": "string", + "required": false, + "example": "Orcish warbands are massing north of Yew.", + "description": "The event summary, as authored." + }, + { + "name": "seriesName", + "type": "string", + "required": false, + "example": "The Yew Campaign", + "description": "The arc this event belongs to, when it belongs to one." + }, + { + "name": "startsAt", + "type": "datetime", + "required": true, + "example": "2026-09-12T20:00:00.000Z", + "description": "When it actually started, UTC." + }, + { + "name": "timezone", + "type": "string", + "required": false, + "example": "America/New_York", + "description": "The shard-local zone the schedule was authored in." + }, + { + "name": "startsAtLabel", + "type": "string", + "required": false, + "example": "Saturday 12 September at 8:00 pm (America/New_York)", + "description": "The start time written out in the shard-local zone, for a mail to read." + } + ] + }, { "id": "news.post", "owner": "core", diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js index e124ac7..b972530 100644 --- a/server/src/config/coreEventActions.js +++ b/server/src/config/coreEventActions.js @@ -22,6 +22,16 @@ // verb per module would be that bound re-implemented once per module, advisory // everywhere, and wrong in the first one that forgot it. // +// **Phase 10 added the last two, and they are the integrations** (EVENTS.md +// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a +// post an editor already wrote and queues it through `announce_jobs`, so the +// town crier and Discord arrive as already-registered legs with their retry and +// their classification rather than as a second delivery pipeline. And +// `core.results.publish` is what makes §F's "publish results" literal: it ranks +// the run's participants and stamps the table published. Both name a game noun +// nowhere, which is why they are core's; six actions is now the whole of what an +// event can do on a deployment with no game module installed at all. +// // **Phase 2 gave all three real bodies**, and between them they exercise every // shape §F's envelope can take: `core.announce` does work and finishes, // `core.wait` finishes while deferring what follows it, and `core.cue` succeeds @@ -413,6 +423,142 @@ const ACTIONS = [ return { ok: true } }, }, + + { + id: 'core.announce.post', + label: 'Announce a post', + description: + 'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.', + + // Nothing in the world changes and nothing is created; a message goes out. + // Same class as `core.announce` and for the same reason. + risk: 'notify', + // The job is queued, the legs deliver, and none of it can be unsent. A + // `ledger` here would put a row in the cleanup ledger that teardown could + // never resolve. + reversible: 'none', + version: 1, + + // **`core.announce` sends a line; this sends an ARTICLE**, and that is the + // whole difference between them (EVENTS.md §J, "News"). Events does not + // write posts — `ctx.posts` is read-only to modules and the CMS is core's — + // so an event that wants prose, an image and a permanent page links a post + // an editor already wrote. What this action adds over `core.announce` is + // therefore not a second transport but a second SHAPE: every leg's + // `dispatch()` takes a post, and this is the one that hands it a real one. + params: [ + { + name: 'postId', + type: 'int', + required: true, + example: 412, + source: 'core.options.posts', + description: 'The published post to announce. Any category.', + }, + ], + + /** + * Queue the post on every registered leg, as this run's announcement. + * + * **The refusals are all `retry: false`**, and each is a thing a human has to + * fix: a post id that names nothing, or a draft. Neither will have changed + * sixty seconds later, and retrying would spend two more attempts before + * saying the same thing. + * + * **What it does NOT wait for is delivery.** `enqueueForRun` writes the job + * and the legs and returns; `announceWorker` drains them on its own tick with + * its own backoff. So this step is `done` when the announcement is queued, + * not when Discord has it — which is honest, because a leg that fails after + * six attempts over two hours is not something a step could usefully have + * stayed open for, and the post admin panel is where that failure is already + * surfaced. + */ + async perform({ runId, params, verify }) { + /* eslint-disable global-require */ + const posts = require('../model/posts/posts.model') + const announceJobs = require('../model/announceJobs/announceJobs.model') + /* eslint-enable global-require */ + + const postId = Number(params.postId) + if (!Number.isInteger(postId) || postId < 1) { + return { ok: false, retry: false, error: `"${params.postId}" is not a post id` } + } + + const post = await posts.getById(postId) + if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` } + if (!post.published) { + // A draft has no public page for a town-crier line to point at, and + // announcing one would publish its title to a shard before an editor + // meant to. Refused rather than published on the author's behalf: + // publishing is the CMS's decision and this action is not it. + return { ok: false, retry: false, error: `"${post.title}" is not published` } + } + + // The dry run has now checked everything worth checking — the post exists + // and is published — and queues nothing. Checked BEFORE the legs are read, + // because a deployment with no leg registered is a real state and a verify + // that reported it as a failure would refuse a plan that is fine. + if (verify) return { ok: true } + + await announceJobs.enqueueForRun(postId, runId) + return { ok: true } + }, + }, + + { + id: 'core.results.publish', + label: 'Publish the results', + description: + 'Rank this run\'s participants by score and publish the results table.', + + // Nothing in the game world changes and nobody is messaged: a table core + // already holds becomes readable. `inspect` is the weakest class the closed + // set has and it is the honest one — which also means this action is + // default-ON like `core.wait`, and an author can place it without an admin + // first visiting the switchboard. + risk: 'inspect', + // **`none`, and it is worth saying why a publication is not reversible.** + // Nothing is created that core would have to come back for; un-publishing is + // an admin decision about a table, not a teardown obligation, and a `ledger` + // row here would make every completed event carry an outstanding resource + // for ever. + reversible: 'none', + version: 1, + + // No params. What is published is this run's participants, which is the only + // set there is — a param naming which run would be a way to publish someone + // else's results from inside your own event. + params: [], + + /** + * Rank, stamp, and say how many. + * + * **Idempotent by construction**, which is what makes it safe as an ordinary + * retried step: ranking is a total order over `(score, joined_at, id)`, so + * running it twice over an unchanged table writes the same numbers, and the + * stamp simply moves. A late participant added by a second collect step and + * a re-publish afterwards renumbers deliberately — that is the operator + * asking for exactly that. + * + * **A run with no participants publishes an empty table rather than + * failing.** "Nobody was recorded" is a true and renderable result, and it is + * the state of every run until a module can source attendance at all (Phase + * 12). Failing here would make an event whose module reports nothing look + * broken on the console for a reason that has nothing to do with the event. + */ + async perform({ runId, verify }) { + /* eslint-disable global-require */ + const participantsDb = require('../model/events/eventRunParticipants.db') + const runsDb = require('../model/events/eventRuns.db') + /* eslint-enable global-require */ + + if (verify) return { ok: true } + + await participantsDb.rankRun(runId) + await runsDb.markResultsPublished(runId) + return { ok: true } + }, + }, ] // ── Core's own param option sources (§F, Phase 7) ────────────────── @@ -446,6 +592,28 @@ const OPTION_SOURCES = [ .map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] })) }, }, + { + id: 'core.options.posts', + label: 'Published posts', + description: 'Every published post an event may announce, newest first.', + /** + * **The one option source in core that reaches a table**, and the reason it + * is allowed to is the rule §F draws about WHEN: a source resolves on its own + * request (`GET /admin/events/catalog/options/:sourceId`), which is a live + * request on a booted server, not at `register()` time under a dead pool. + * + * Grouped by category so the dropdown separates news from the newsletter + * rather than presenting one long list in which the two are indistinguishable + * — a `group` is what the form renders as an optgroup, and it costs a column + * that is already selected. + */ + async resolve() { + // eslint-disable-next-line global-require + const postsDb = require('../model/posts/posts.db') + const rows = await postsDb.listPublishedForOptions(200) + return rows.map((p) => ({ value: p.id, label: p.title, group: p.category })) + }, + }, ] module.exports = { ACTIONS, OPTION_SOURCES } diff --git a/server/src/config/coreStreams.js b/server/src/config/coreStreams.js index 20e3bc1..e9fa49d 100644 --- a/server/src/config/coreStreams.js +++ b/server/src/config/coreStreams.js @@ -75,6 +75,38 @@ const STREAMS = [ personal: false, requiresLinkedAccount: false, }, + + // ── The event system (EVENTS.md §J — Phase 10) ────────────────────────── + // + // **One of the seven `event.` triggers is also a stream, and that is a + // decision rather than an oversight** (org lead, 2026-09-04). A stream is a + // PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only + // for ids registered here, `publishToUsers` joins `notification_subscriptions`, + // and that table is only ever written for a channel a user could switch on. So + // a trigger that is not also a stream can be mailed and put in the inbox, and + // its push is dead — a tickle published to nobody, which the send log + // nonetheless records as sent. Found on the live rig; the seeded rule named + // `push` before this line existed. + // + // **`run.started` alone, because push is the channel that says "now".** It is + // the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's + // *"come back for X"* — and the other six are things a player reads when they + // next look. Six more toggles would put a wall of switches on the preferences + // screen for one feature, and `event.phase.changed` is the one most likely to + // buzz a phone four times in an evening. + // + // Same id as the trigger, which is §7.2's one namespace and the same-owner + // upgrade `news.post` already is: one id, one owner, two facets. + { + id: 'event.run.started', + label: 'Events — starting now', + description: 'A scheduled event is beginning.', + // Not owner-keyed: this is a public event happening in public, not a fact + // about one account's own property. Same as `news.post`. + personal: false, + // A player with no linked game account can still want to know an event is on. + requiresLinkedAccount: false, + }, ] module.exports = { STREAMS } diff --git a/server/src/config/coreTriggers.js b/server/src/config/coreTriggers.js index d9ff10a..eba59d7 100644 --- a/server/src/config/coreTriggers.js +++ b/server/src/config/coreTriggers.js @@ -149,6 +149,232 @@ const TRIGGERS = [ description: 'Site-relative path to the announcement.' }, ], }, + + // ── The event system (EVENTS.md §J — Phase 10) ────────────────────────── + // + // **Seven triggers, one per moment a run passes through that somebody outside + // the run console might want to hear about — and Events owns none of the + // delivery.** A run emits; an operator's rule decides who is told, on what, + // and how often. That is the whole of §J's "clean fit" row, and it is why + // there is no announcement machinery anywhere in `utils/eventRunner.js` + // beyond a call to `emit`. + // + // **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org + // lead 2026-09-04). `run.failed` is an operational fact — a step ran out of + // attempts, the world may be half-changed — and a rule that mailed it to + // every subscriber would publish the deployment's incidents. The other six + // describe a public event happening in public, so they sit exactly where + // `news.post` sits: ceiling `authenticated`, default audience `subscribers`, + // which is "people who asked to be told" rather than the whole user table. + // + // **Every `description` here is read by two audiences**, and the second one is + // easy to forget: the rule editor's catalog, and — through + // `projection.project`'s `intro` fallback — every recipient of an unauthored + // render through `notify.event` or `inapp.event`. So each is prose a player + // can read rather than a note to the operator. The live rig caught the + // original `run.failed` line, which ended "Staff-facing." and put those words + // in an administrator's own inbox item. Who a trigger is for is said by its + // CEILING, which is the only place that can enforce it anyway. + // + // **`subjectKey: 'runId'` on every one of them**, and it is the one place + // these differ from `news.post`. A cooldown keyed on the user would make + // `phase.changed` mean "at most one phase of at most one event an hour", + // silently swallowing the second wave of an invasion because the first wave's + // mail went out forty minutes ago. Keyed on the run it means "at most one + // line an hour ABOUT THIS RUN", which is the useful sentence — and across + // runs of the same definition the ids differ, so a weekly event is not + // throttled by last week's. + // + // **None of the six public ones declares a `url` variable, deliberately.** + // There is no public event page until Phase 14 — `App.jsx` mounts nothing + // under `/site/events` — and `news.post` has already paid for this mistake + // once: its `postUrl` example named `/news/`, a path that does not + // exist, and the template editor previewed a link that was dead in every mail + // it sent. A variable added in Phase 14 alongside the page it points at is a + // version bump; a variable shipped now is a 404 in an operator's first + // announcement. `run.failed` is the exception because its destination exists + // today: `/admin/events/runs/:runId` is a real route and an admin can read it. + { + id: 'event.run.scheduled', + label: 'Event — scheduled', + description: 'A new event has been added to the calendar.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', + description: 'The event summary, as authored.' }, + { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign', + description: 'The arc this event belongs to, when it belongs to one.' }, + { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z', + description: 'When the occurrence is due to start, UTC.' }, + { name: 'timezone', type: 'string', required: false, example: 'America/New_York', + description: 'The shard-local zone the schedule was authored in.' }, + // **A presentational fragment, and §4.6.1 convention 1 is what sanctions + // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO + // string — correct as data and unreadable in a mail, and a template has no + // logic with which to format it. So the formatting happens at the emitter, + // in the shard-local zone, and arrives as a variable whose `example` shows + // exactly what it produces. Same trade `forWhom` makes in the auth bodies. + { name: 'startsAtLabel', type: 'string', required: false, + example: 'Saturday 12 September at 8:00 pm (America/New_York)', + description: 'The start time written out in the shard-local zone, for a mail to read.' }, + ], + }, + { + id: 'event.run.started', + label: 'Event — starting now', + description: 'A scheduled event has begun.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', + description: 'The event summary, as authored.' }, + { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign', + description: 'The arc this event belongs to, when it belongs to one.' }, + { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z', + description: 'When it actually started, UTC.' }, + { name: 'timezone', type: 'string', required: false, example: 'America/New_York', + description: 'The shard-local zone the schedule was authored in.' }, + // **A presentational fragment, and §4.6.1 convention 1 is what sanctions + // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO + // string — correct as data and unreadable in a mail, and a template has no + // logic with which to format it. So the formatting happens at the emitter, + // in the shard-local zone, and arrives as a variable whose `example` shows + // exactly what it produces. Same trade `forWhom` makes in the auth bodies. + { name: 'startsAtLabel', type: 'string', required: false, + example: 'Saturday 12 September at 8:00 pm (America/New_York)', + description: 'The start time written out in the shard-local zone, for a mail to read.' }, + ], + }, + { + id: 'event.phase.changed', + label: 'Event — a new phase', + description: 'An event that is under way has moved on to its next stage.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'phase', type: 'string', required: true, example: 'assault', + description: 'The phase key just entered, as authored in the spec.' }, + { name: 'phaseLabel', type: 'string', required: false, example: 'The assault', + description: 'The phase label, when the spec gave it one. Falls back to the key.' }, + { name: 'phaseIndex', type: 'int', required: true, example: 2, + description: 'Which phase this is, counting from 1.' }, + { name: 'phaseCount', type: 'int', required: true, example: 4, + description: 'How many phases the pinned version has in total.' }, + ], + }, + { + id: 'event.run.ending', + label: 'Event — winding down', + description: 'An event is drawing to a close.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + ], + }, + { + id: 'event.run.completed', + label: 'Event — finished', + description: 'An event has finished.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', + description: 'The event summary, as authored.' }, + // Counted from `event_run_participants` at emit. Zero on a run whose + // module reported nobody, which is every run until a module collects — + // a template that says "47 took part" needs a number that is never + // missing, and "0" is the honest one. + { name: 'participantCount', type: 'int', required: true, example: 47, + description: 'How many participants the run recorded. Zero when nothing collected any.' }, + { name: 'durationMinutes', type: 'int', required: true, example: 95, + description: 'How long the run took, start to end, in whole minutes.' }, + ], + }, + { + id: 'event.run.cancelled', + label: 'Event — cancelled', + description: 'A scheduled event was cancelled by a member of staff.', + kind: 'event', + subjectKey: 'runId', + audience: 'subscribers', + ceiling: 'authenticated', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + // **The operator's reason, and not the run's `last_error`.** `cancel` + // takes a `{ reason }` a human typed for other humans; a diagnostic + // string is for the run console and would read as gibberish in a mail. + { name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.', + description: 'What the staff member gave as the reason, when they gave one.' }, + ], + }, + { + id: 'event.run.failed', + label: 'Event — run failed', + description: 'An event stopped before it finished.', + kind: 'event', + subjectKey: 'runId', + // **`admin`, and both halves of that.** The ceiling is the security + // boundary (§J, G24): no rule may ever widen this past admins, because a + // failure names the deployment's own broken machinery. The default audience + // matches, so a rule created from this trigger starts where it must end. + audience: 'admin', + ceiling: 'admin', + version: 1, + variables: [ + { name: 'runId', type: 'string', required: true, example: '3692', + description: 'The run this is about. Also the cooldown subject.' }, + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion', + description: 'The event title.' }, + { name: 'phase', type: 'string', required: false, example: 'assault', + description: 'The phase it failed in, when it had entered one.' }, + { name: 'error', type: 'string', required: false, example: 'sidecar responded 503', + description: 'The run’s last error, verbatim from the run row.' }, + // The one url variable in this file's Phase 10 block, and the reason is + // that this route exists TODAY. See the note above the six. + { name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692', + description: 'Site-relative path to the run console.' }, + ], + }, ] module.exports = { TRIGGERS } diff --git a/server/src/engagement/audiences.js b/server/src/engagement/audiences.js index 29a9285..ff10a76 100644 --- a/server/src/engagement/audiences.js +++ b/server/src/engagement/audiences.js @@ -156,11 +156,27 @@ async function resolveForRule(rule, event) { * same id more tightly. That is precisely the case where a stale rule would * otherwise mail a population the current declaration forbids, which is what * makes this the security boundary rather than a duplicate check. + * + * **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may + * carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the + * effective bound is the MEET of the two, so a firing can only ever narrow what + * the declaration allows. Two incomparable ceilings meet to null and the gate + * refuses: `owner` and `staff` have no common descendant, and picking one would + * be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value + * cannot get here — `emit` validates it against the same lattice — but the null + * is handled anyway, because this is the boundary and a boundary that trusts its + * caller is not one. + * + * @param {string} triggerId + * @param {string} ceiling the audience the rule resolved to + * @param {string|null} [emitted] a narrowing ceiling this firing carries */ -function permitted(triggerId, ceiling) { +function permitted(triggerId, ceiling, emitted = null) { const declaration = registries.eventTrigger(triggerId) if (!declaration) return false - return ceilings.permits(declaration.ceiling, ceiling) + const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling + if (!bound) return false + return ceilings.permits(bound, ceiling) } module.exports = { resolveForRule, permitted, defaultOnChannels } diff --git a/server/src/engagement/coreRules.js b/server/src/engagement/coreRules.js index e819f3d..f573469 100644 --- a/server/src/engagement/coreRules.js +++ b/server/src/engagement/coreRules.js @@ -1,4 +1,4 @@ -// ── The five rules core ships, all of them OFF ───────────────────────────── +// ── The seven rules core ships, all of them OFF ──────────────────────────── // // ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed // people with no operator configuration at all: the code decided who was mailed @@ -34,6 +34,11 @@ // on and no way to tell why. One key per seed GROUP is the rule this establishes; // a sixth rule for a new trigger takes a sixth key, and a rule added to an // existing group is a rule that only fresh installs will ever see. +// +// **EVENTS.md Phase 10 added two more, and a third key**, for the event +// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third +// application — a deployment that has already stamped the news key must still +// see these. const rulesDb = require('../model/engagement/engagementRules.db') const settingsDb = require('../model/settings/settings.db') @@ -46,6 +51,9 @@ const SEEDED_KEY = 'engagement_team_rules_seeded' // Phase 11's, and separate for the reason above. Same shape, same semantics. const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded' +// EVENTS.md Phase 10's, third group, third key. +const EVENT_SEEDED_KEY = 'engagement_event_rules_seeded' + const RULES = [ { trigger_id: 'team.forum.post', @@ -145,6 +153,73 @@ const NEWS_RULES = [ }, ] +// Phase 10's two, in their own list under their own one-shot key — the rule +// Phase 11 established, applied for the second time. Appending to `NEWS_RULES` +// would seed these on fresh installs only and on exactly the upgrades that want +// them, never. +// +// **Two rules for seven triggers, and that is the whole decision** (org lead, +// 2026-09-04). Every one of the seven is declared, so an operator can write a +// rule against any of them from the rules screen; what is SEEDED is the pair +// somebody would otherwise have to build from scratch on the first day — the +// player-facing "it is starting" and the staff-facing "it broke". Seeding all +// seven would grow Admin → Engagement → Rules by seven disabled rows nobody +// asked for, and `event.phase.changed` is the one most likely to be switched on +// by accident and then mail a player four times in one evening. +const EVENT_RULES = [ + { + trigger_id: 'event.run.started', + name: 'Events — starting now', + // `subscribers`, the trigger's own default: people who opted into this id on + // at least one channel. Not `authenticated`, even though the ceiling permits + // it — an event is worth telling people who asked to be told about events, + // and mailing the whole user table every Saturday night is how a feature + // earns a spam complaint. An operator who wants the whole site can widen it; + // the ceiling is what stops them widening it past that. + audience: 'subscribers', + // All three, like the news rule and for the same reason: push is the channel + // that gets somebody to log in *now*, which is the entire point of a + // "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is + // the surface a content-free tickle deep-links into. + channels: ['email', 'inapp', 'push'], + // The one bespoke body this phase seeds; see `templateSeeds.js` for why it + // is one and not seven. `inapp.event` is the in-app renderer's generic, and + // push carries no content by construction and needs no template. + template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' }, + // An hour, per user PER RUN — `event.run.started` declares `subjectKey: + // 'runId'`, so the cooldown subject is the run and not the recipient. It is + // near-redundant on a trigger that fires once per run, which is the point: + // it costs nothing and it is the guard if a run is ever restarted. + cooldown_seconds: 3600, + max_sends_per_hour: 1000, + }, + { + trigger_id: 'event.run.failed', + name: 'Events — a run failed', + // `admin`, which is both the trigger's default and its ceiling. A failed run + // names the deployment's own broken machinery — a sidecar that did not + // answer, a step that ran out of attempts — and there is no widening of this + // that is not a disclosure. + audience: 'admin', + // No push. An admin's phone buzzing at four in the morning for a step that + // will still be failed at breakfast is a notification people switch off + // wholesale, and switching it off wholesale is how the one that mattered is + // missed. Mail and the inbox both wait. + channels: ['email', 'inapp'], + // The generic body plus the structural projection: `event.run.failed` + // declares its own `title` and a `runUrl`, so an unauthored mail is already + // headed with the event's name and buttoned through to the run console — + // §4.6.1 property 1, working exactly as it promises. + template_keys: { email: 'notify.event', inapp: 'inapp.event' }, + // **No cooldown, and this is the one rule in the file that must not have + // one.** The subject is the run, so a cooldown would only ever suppress a + // second failure of the SAME run — which is precisely the run an + // administrator most needs the second line about. + cooldown_seconds: 0, + max_sends_per_hour: 200, + }, +] + /** * Seed one group of rules, once, under its own guard key. * @@ -185,7 +260,7 @@ async function seedGroup(key, rules, note) { }) summary.inserted += 1 } catch (err) { - log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message }) + log.error('rule seed failed', { key, trigger: rule.trigger_id, message: err.message }) } } // Stamped even on a partial run — the claim above is the stamp. Re-running @@ -209,6 +284,10 @@ const seedTeamRules = () => const seedNewsRule = () => seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule') +/** The two event-lifecycle rules (EVENTS.md Phase 10). */ +const seedEventRules = () => + seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one') + /** * Both groups, which is what the boot path calls. * @@ -219,9 +298,10 @@ const seedNewsRule = () => async function seedCoreRules() { const team = await seedTeamRules() const news = await seedNewsRule() + const events = await seedEventRules() return { - inserted: team.inserted + news.inserted, - skipped: team.skipped + news.skipped, + inserted: team.inserted + news.inserted + events.inserted, + skipped: team.skipped + news.skipped + events.skipped, } } @@ -229,8 +309,11 @@ module.exports = { seedCoreRules, seedTeamRules, seedNewsRule, + seedEventRules, RULES, NEWS_RULES, + EVENT_RULES, SEEDED_KEY, NEWS_SEEDED_KEY, + EVENT_SEEDED_KEY, } diff --git a/server/src/engagement/engine.js b/server/src/engagement/engine.js index e1447c1..52d3b88 100644 --- a/server/src/engagement/engine.js +++ b/server/src/engagement/engine.js @@ -150,13 +150,21 @@ async function applyRule(rule, event, now) { // G24, re-run at send time. A rule saved when its trigger permitted a wider // audience must not keep reaching it after a module upgrade narrowed the - // declaration - and that is the only way this can fail, since the save path + // declaration - and that was the only way this could fail, since the save path // ran the same check. - if (!audiences.permitted(event.triggerId, resolved.ceiling)) { + // + // **Phase 10 gave it a second way, and it is the one that fires in practice:** + // the event may carry a narrowing ceiling of its own. A rehearsal emits + // `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote + // for the real thing is then refused here rather than mailing subscribers about + // an event that is not happening. Nothing about the rule changed; the occasion + // did. See `audiences.permitted`. + if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) { log.warn('rule audience exceeds its trigger ceiling - refusing', { rule: rule.id, trigger: event.triggerId, audience: resolved.ceiling, + emitted: event.ceiling || null, }) summary.skipped = 'ceiling' return summary diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js index d77e0f0..2088c40 100644 --- a/server/src/engagement/templateSeeds.js +++ b/server/src/engagement/templateSeeds.js @@ -310,6 +310,56 @@ const SEEDS = [ button('cta', 'Open', '{{actionUrl}}'), ], }, + // ── The event system (EVENTS.md §J — Phase 10) ───────────────────────── + // + // **One body, not seven.** Six of the seven `event.` triggers render through + // `notify.event` and the structural projection with no authoring at all + // (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail + // is already headed with the event's name — and seeding a bespoke body per + // trigger would be seven templates an operator has to maintain to change one + // sentence. + // + // `event.run.started` gets one because it is the flagship: the mail that + // answers §8.5's *"Come back for X — a scheduled event is starting"*, the one + // an operator will actually enable, and the one where the generic body reads + // visibly worse — `notify.event` renders the title over the TRIGGER's + // description, while this reads the payload's own names and says what is + // starting, when, and what arc it belongs to. Same argument `notify.team-post` + // makes beside the generic body, one feature along. + // + // **Every optional line is one token on its own**, which is this template + // language's whole conditional (see `email.text`: a block whose content is a + // single absent variable renders nothing, in both parts). A standalone event + // has no `seriesName` and its line disappears rather than reading "Part of .". + // + // **No `{{actionUrl}}` and no button, deliberately.** There is no public event + // page until Phase 14, so the six public triggers declare no `url` variable at + // all (see `coreTriggers.js`), and a button here would render as an inert grey + // label in every mail — worse than none, because it advertises a link the + // reader cannot follow. Phase 14 adds the variable and the block together. + { + key: 'notify.event-started', + name: 'Event starting', + channel: 'email', + protected: false, + seedVersion: 1, + subject: '{{title}} is starting', + variables: [ + { name: 'title', type: 'string', required: true, example: 'The Yew Invasion' }, + { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' }, + { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' }, + { name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' }, + { name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' }, + ], + blocks: [ + heading('h', '{{title}}'), + text('summary', '{{summary}}'), + text('when', '{{startsAtLabel}}', { muted: true }), + text('series', '{{seriesName}}', { muted: true }), + divider('rule'), + button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'), + ], + }, ] /** @returns {object|null} the seed definition for `key`. */ diff --git a/server/src/events/announce.js b/server/src/events/announce.js new file mode 100644 index 0000000..e4813f0 --- /dev/null +++ b/server/src/events/announce.js @@ -0,0 +1,225 @@ +// ── A run's lifecycle, told to the engagement engine ─────────────────────── +// +// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life +// become seven `event.` triggers, and **Events owns none of the delivery**. +// +// That sentence is the whole design and it is worth being exact about what it +// buys. Nothing in this file knows what email is, whether anyone is subscribed, +// what a template says, or how often somebody may be mailed. It says a thing +// happened, with the facts the declaration asked for; an operator's rule decides +// the rest. Every announcement channel the platform has — email, the in-app +// inbox, content-free push tickles, Discord and the town crier through the +// announce legs — arrives for free the day a rule points at one, and none of +// them arrives by anything in `events/` growing a second delivery path. +// +// **Nothing here throws and nothing here is awaited for its answer.** `emit` +// itself is fire-and-forget by construction (see `engagementEmit`'s header) — +// the whole point of the seam is that the emitter does not wait on rule lookups +// and a dozen inserts. What IS awaited here is the read that assembles the +// payload, and it is wrapped: a run must not fail to start because the row that +// says what it is called could not be read. +// +// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for +// real with announcements ceilinged to `staff`". Every emit below carries +// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the +// same rules are evaluated, the same log lines are written — and the only rules +// that survive the G24 gate are ones whose audience a staff member is in. A +// rehearsal that emitted nothing would be a rehearsal of everything except the +// announcements, which are the part most worth rehearsing. + +const definitionsDb = require('../model/events/eventDefinitions.db') +const participantsDb = require('../model/events/eventRunParticipants.db') +const logDb = require('../model/events/eventRunLog.db') +const engagementEmit = require('../utils/engagementEmit') +const log = require('../utils/logger')('events') + +// §I, and the one place a rehearsal differs from the real thing on the announce +// path. `staff` rather than `admin` because a rehearsal is the event team's +// dress run and a moderator on it should see what an attendee would. +const REHEARSAL_CEILING = 'staff' + +/** + * The start time written out in the shard-local zone, for a mail to read. + * + * **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1 + * convention 1). `startsAt` also goes down the wire as a `datetime`, which the + * seam normalises to an ISO string — right as data and wrong in a sentence — and + * a template has no logic with which to format one. The zone is the shard's own, + * because "8pm" means the shard's evening to everyone reading it and the + * recipient's browser is not in the room when a mail is rendered. + * + * A bad zone answers null rather than throwing: `Intl` rejects an unknown + * identifier, and an event whose timezone column holds a typo must still + * announce. The variable is optional and its block is one token, so an absent + * label renders as nothing at all rather than as a broken line. + */ +function startsAtLabel(at, zone) { + const when = at instanceof Date ? at : new Date(at) + if (Number.isNaN(when.getTime())) return undefined + try { + const text = new Intl.DateTimeFormat('en-GB', { + timeZone: zone || 'UTC', + weekday: 'long', + day: 'numeric', + month: 'long', + hour: 'numeric', + minute: '2-digit', + // Explicit rather than left to the locale, because `en-GB` would otherwise + // render midnight as "00:00" while the schedule editor beside it writes + // "12:00 AM" — one event, two spellings of the same instant. + hour12: true, + }).format(when) + return `${text} (${zone || 'UTC'})` + } catch { + return undefined + } +} + +/** + * The facts every `event.` trigger shares, read once per emit. + * + * A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no + * series, no summary — so the definition is fetched here rather than threaded + * through every call site in the runner. It is one indexed read per lifecycle + * transition, which is a handful per run. + */ +async function baseFor(run) { + const definition = await definitionsDb.getById(run.definition_id) + if (!definition) return null + return { + runId: String(run.id), + title: definition.title, + summary: definition.summary || undefined, + seriesName: definition.series_name || undefined, + timezone: run.timezone || definition.timezone || undefined, + definition, + } +} + +/** + * Fire one lifecycle trigger. + * + * `extra` is merged over the shared facts and may drop any of them — a payload + * key set to `undefined` is simply absent, and `validatePayload` treats absent + * and null alike, so a trigger that declares fewer variables than this assembles + * is not a problem: undeclared keys are dropped at the seam and logged as a + * debug line rather than refused. + * + * Answers nothing. Every caller is a transition in the runner and none of them + * has anything it could correctly do with a failure of core's own notification + * bookkeeping. + */ +async function fire(run, triggerId, extra = {}) { + try { + const base = await baseFor(run) + if (!base) { + // The definition is gone. `event_runs.definition_id` cascades on delete, so + // this is a race with an archive rather than an ordinary state — nothing to + // announce and nothing broken. + return + } + const { definition, ...facts } = base + const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined + + engagementEmit.emit('core', triggerId, { + // The run, not the definition. Two occurrences of a weekly event are two + // subjects, so last week's mail does not throttle this week's — and within + // one run a cooldown means "at most one line an hour about THIS", which is + // the sentence an operator writing `phase.changed` actually wants. + subject: String(run.id), + // Bounded and stable, because it ends up in a signed unsubscribe token that + // will sit in a mailbox for months. A run id is both. + scopeKey: `event:${run.id}`, + ceiling, + data: { ...facts, ...extra }, + }) + + // Written here rather than at each call site: what an operator wants in the + // run log is that the run SAID something happened, and with what bound. How + // many people were told is the engagement engine's own log line and its own + // decision — a run log that claimed to know the number would be reporting a + // decision it does not make. + await logDb.write({ + runId: run.id, + kind: 'announcement.emitted', + phase: run.current_phase || null, + detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) }, + }) + } catch (err) { + log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message }) + } +} + +// ── One function per moment, so the runner names a moment and not a payload ── +// +// The alternative — `fire(run, 'event.run.started', { … })` at each call site — +// would put the payload assembly in `eventRunner.js`, where a change to a +// declaration becomes a change to the runner. These are the seam. + +const runScheduled = (run) => + fire(run, 'event.run.scheduled', { + startsAt: run.scheduled_for, + startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone), + }) + +const runStarted = (run, startedAt) => { + const at = startedAt || run.started_at || new Date() + return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) }) +} + +const phaseChanged = (run, { phase, label, index, count }) => + fire(run, 'event.phase.changed', { + phase, + phaseLabel: label || phase, + // One-based, because it is read by a human in a sentence. Every caller + // passes the zero-based index it already has and the conversion is here, in + // one place, rather than at three call sites where two of them would drift. + phaseIndex: index + 1, + phaseCount: count, + }) + +const runEnding = (run) => fire(run, 'event.run.ending') + +async function runCompleted(run, endedAt) { + // Counted at emit rather than carried by the caller: the last thing a run does + // before completing is its teardown, and a module's collect step may have + // written rows within the same tick. + let participantCount = 0 + try { + participantCount = await participantsDb.countForRun(run.id) + } catch (err) { + // Declared `required`, so it has to be a number. Zero is the honest answer + // for a count that could not be read, and it is also the answer for the far + // more common case of a run nothing collected for. + log.warn('participant count unavailable for announcement', { run: run.id, message: err.message }) + } + const started = run.started_at ? new Date(run.started_at) : null + const ended = endedAt ? new Date(endedAt) : new Date() + const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0 + return fire(run, 'event.run.completed', { participantCount, durationMinutes }) +} + +const runCancelled = (run, reason) => + fire(run, 'event.run.cancelled', { reason: reason || undefined }) + +const runFailed = (run, error) => + fire(run, 'event.run.failed', { + phase: run.current_phase || undefined, + error: error || run.last_error || undefined, + // The one destination that exists today. See `coreTriggers.js`'s note above + // the six public declarations for why none of them has one. + runUrl: `/admin/events/runs/${run.id}`, + }) + +module.exports = { + fire, + startsAtLabel, + runScheduled, + runStarted, + phaseChanged, + runEnding, + runCompleted, + runCancelled, + runFailed, + REHEARSAL_CEILING, +} diff --git a/server/src/events/dispatch.js b/server/src/events/dispatch.js index 3ac9e32..f1fa8aa 100644 --- a/server/src/events/dispatch.js +++ b/server/src/events/dispatch.js @@ -61,7 +61,8 @@ function withDeadline(fn, ms, actionId) { } /** - * Turn a raw `perform()` answer into `{ outcome, error?, holdSeconds?, resources? }`. + * Turn a raw `perform()` answer into + * `{ outcome, error?, holdSeconds?, resources?, participants? }`. * * Exported and pure, so the classification rules are testable without a registry, * a database or a clock — which matters because they are the rules that decide @@ -97,7 +98,12 @@ function classify(result, actionId) { // never names a verb. `core.cue` and `core.wait` reach them through the same // door Phase 7 opens to a module's own long-running action. if (result.await === 'human') { - return { outcome: 'parked', error: null, resources: result.resources || [] } + return { + outcome: 'parked', + error: null, + resources: result.resources || [], + participants: result.participants || [], + } } let holdSeconds = 0 @@ -109,7 +115,17 @@ function classify(result, actionId) { holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS) } - return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [] } + // `participants` rides beside `resources` and on the same two success shapes + // (Phase 10). It is carried rather than interpreted here: what a member key + // means is the module's business, and this file's whole job is to know + // nothing about the verb it just called. + return { + outcome: 'done', + error: null, + holdSeconds, + resources: result.resources || [], + participants: result.participants || [], + } } /** diff --git a/server/src/events/participants.js b/server/src/events/participants.js new file mode 100644 index 0000000..c63efe9 --- /dev/null +++ b/server/src/events/participants.js @@ -0,0 +1,162 @@ +// ── Recording who took part ──────────────────────────────────────────────── +// +// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of +// `events/ledger.js`: that file records what a run did to the world, this one +// records who it happened to. +// +// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An +// action answers `{ ok: true, participants: [...] }` and the runner writes them +// beside the resources, on the same two success shapes, through the same +// classify → record path. There is no `ctx.events.participants` API and no route: +// a second write path into a run core is mid-tick on would be a second thing that +// can race the claim, for a caller that does not exist until a module can source +// the data at all (Phase 11's plugin-side participation ledger; Phase 12's +// collect step is the first consumer). +// +// **Core cannot source a participant and does not try.** §J: `member_key` is +// module-opaque, `user_id` is filled in by whoever knows the link table. For +// module-uo that is `shard_links`; for another game it is something else, and a +// core that guessed would be one game's identity model compiled into core. So a +// module reports both halves, or reports the key alone and the row stays +// anonymous — which is the honest record of an unlinked player who turned up. +// +// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s +// posture and for exactly its reason: a malformed participant will be just as +// malformed on the second attempt, and failing the step would re-dispatch a +// world write that already happened. Rejections are logged and surfaced on the +// run log so an author can see what their module sent. + +const participantsDb = require('../model/events/eventRunParticipants.db') +const log = require('../utils/logger')('events') + +// Bounded to the column, and refused rather than truncated: a truncated member +// key is a different participant, and under `uq_evpart_member` it would silently +// merge two people into one row. +const MAX_MEMBER_KEY = 190 + +// The most one step may report. A run's participants are people, and a step +// answering with a hundred thousand of them is a module bug rather than a very +// popular event — one that would otherwise spend a tick's whole budget on +// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement +// engine is 5000 for the same class of reason and this matches it deliberately: +// the two bound the same thing, a list of users one call may assert. +const MAX_PER_STEP = 5000 + +/** + * Turn one entry of a module's `participants` array into a row, or say why not. + */ +function normalise(entry, actionId) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return { ok: false, reason: `${actionId} reported a participant that is not an object` } + } + + const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey) + if (!memberKey || memberKey.length > MAX_MEMBER_KEY) { + return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` } + } + + // Optional, and checked rather than coerced. A `userId` is a foreign key into + // `users`, so a module that passed a character serial here would either fail + // the insert or — worse, if the number happened to be a real user — attribute + // somebody else's attendance to a stranger. + let userId = null + if (entry.userId !== undefined && entry.userId !== null) { + if (!Number.isInteger(entry.userId) || entry.userId < 1) { + return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` } + } + userId = entry.userId + } + + // `score` is optional and defaults to 0 — a run that only records attendance + // is a run where everybody scored nothing, which is a true statement and a + // renderable table. Non-finite is refused rather than coerced: `NaN` written + // into a DECIMAL would either throw at the driver or land as 0, and a 0 that + // meant "the module sent nonsense" is indistinguishable from an honest zero. + let score = 0 + if (entry.score !== undefined && entry.score !== null) { + const n = Number(entry.score) + if (!Number.isFinite(n)) { + return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` } + } + score = n + } + + let joinedAt = null + if (entry.joinedAt !== undefined && entry.joinedAt !== null) { + const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt) + if (Number.isNaN(at.getTime())) { + return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` } + } + joinedAt = at + } + + return { + ok: true, + row: { + memberKey, + userId, + score, + // Opaque, exactly like a resource's payload: core stores it and never + // reads it. Anything but an object is dropped rather than refused — + // `meta` is decoration on a row whose identity is already valid, and + // losing an event's whole attendance over a stray string would be the + // wrong trade. + meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null, + joinedAt, + }, + } +} + +/** + * Record what a module said about who took part. + * + * Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a + * step that changed the world has changed it, and a bookkeeping failure must not + * become a retry of a world write. + */ +async function recordAnswer({ run, step, action, participants }) { + const out = { recorded: 0, rejected: [] } + const list = Array.isArray(participants) ? participants : [] + if (!list.length) return out + + if (list.length > MAX_PER_STEP) { + // Refused whole rather than truncated. Half a leaderboard silently cut at + // five thousand is worse than none: the table would look complete and be + // wrong, and nothing downstream could tell. + const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may` + log.warn('event participants refused', { run: run.id, step: step.id, reason }) + return { recorded: 0, rejected: [reason] } + } + + // **Deduplicated in memory before the write.** One step reporting the same + // member twice is a module bug, and letting both reach the upsert would make + // the LAST one win silently. Refusing the step would be worse — the other + // ninety-nine participants are fine — so the first wins and the duplicate is + // named, which is a thing an author can act on. + const seen = new Set() + + for (const entry of list) { + const parsed = normalise(entry, action.id) + if (!parsed.ok) { + out.rejected.push(parsed.reason) + log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason }) + continue + } + if (seen.has(parsed.row.memberKey)) { + out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`) + continue + } + seen.add(parsed.row.memberKey) + try { + await participantsDb.record({ runId: run.id, ...parsed.row }) + out.recorded += 1 + } catch (err) { + out.rejected.push(err.message) + log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message }) + } + } + + return out +} + +module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP } diff --git a/server/src/model/announceJobs/announceJobs.db.js b/server/src/model/announceJobs/announceJobs.db.js index 7c51a20..e4804d5 100644 --- a/server/src/model/announceJobs/announceJobs.db.js +++ b/server/src/model/announceJobs/announceJobs.db.js @@ -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 diff --git a/server/src/model/announceJobs/announceJobs.model.js b/server/src/model/announceJobs/announceJobs.model.js index a82ffcd..31c63c2 100644 --- a/server/src/model/announceJobs/announceJobs.model.js +++ b/server/src/model/announceJobs/announceJobs.model.js @@ -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, diff --git a/server/src/model/events/eventRunControls.model.js b/server/src/model/events/eventRunControls.model.js index a0ad08e..23763fe 100644 --- a/server/src/model/events/eventRunControls.model.js +++ b/server/src/model/events/eventRunControls.model.js @@ -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 diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js index 6abb204..19486a1 100644 --- a/server/src/model/events/eventRunLog.db.js +++ b/server/src/model/events/eventRunLog.db.js @@ -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) } diff --git a/server/src/model/events/eventRunParticipants.db.js b/server/src/model/events/eventRunParticipants.db.js new file mode 100644 index 0000000..4c54f88 --- /dev/null +++ b/server/src/model/events/eventRunParticipants.db.js @@ -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 } diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js index 5b224ff..86659c8 100644 --- a/server/src/model/events/eventRuns.db.js +++ b/server/src/model/events/eventRuns.db.js @@ -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, diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js index dd00149..9a11371 100644 --- a/server/src/model/events/eventRuns.model.js +++ b/server/src/model/events/eventRuns.model.js @@ -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, + })), } } diff --git a/server/src/model/posts/posts.db.js b/server/src/model/posts/posts.db.js index 6e6b499..adf9213 100644 --- a/server/src/model/posts/posts.db.js +++ b/server/src/model/posts/posts.db.js @@ -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 `` — is exercised too + // rather than only the raw `resolve()`. + const answer = await registries.resolveOptionSource('core.options.posts') + assert.equal(answer.ok, true) + assert.deepEqual(answer.options, [ + { value: '1', label: 'The Yew Invasion', group: 'news' }, + { value: '3', label: 'Never announced', group: 'newsletter' }, + ]) + } finally { + postsDb.listPublishedForOptions = saved + } +}) + +// ── the seeded rules ─────────────────────────────────────────────────────── + +const declared = (id) => TRIGGERS.find((t) => t.id === id) +const seededKeys = new Set(templateSeeds.SEEDS.map((s) => s.key)) + +test('two rules are seeded, both for triggers core declares', () => { + assert.equal(coreRules.EVENT_RULES.length, 2) + for (const rule of coreRules.EVENT_RULES) { + assert.ok(declared(rule.trigger_id), `${rule.trigger_id} is not declared`) + } +}) + +test('every seeded rule names templates that exist', () => { + // A rule pointing at a template that does not exist fails on the first firing + // after an operator switches it on, which is the worst moment to find out. + for (const rule of coreRules.EVENT_RULES) { + for (const [channel, key] of Object.entries(rule.template_keys)) { + assert.ok(seededKeys.has(key), `${rule.trigger_id}.${channel} names "${key}", which is not seeded`) + } + } +}) + +test('no seeded rule asks for an audience its trigger\'s ceiling forbids', () => { + for (const rule of coreRules.EVENT_RULES) { + const trigger = declared(rule.trigger_id) + assert.ok( + ceilings.permits(trigger.ceiling, rule.audience), + `${rule.trigger_id} is ceilinged ${trigger.ceiling} and the seeded rule asks for ${rule.audience}`, + ) + } +}) + +test('the failure rule stays at admin and carries no push', () => { + const failed = coreRules.EVENT_RULES.find((r) => r.trigger_id === 'event.run.failed') + assert.equal(failed.audience, 'admin') + // An admin's phone buzzing at four in the morning for a step that will still + // be failed at breakfast is a notification people switch off wholesale — and + // switching it off wholesale is how the one that mattered is missed. + assert.ok(!failed.channels.includes('push')) + // No cooldown, and it is the one rule here that must not have one: the subject + // is the run, so a cooldown would only ever suppress a second failure of the + // very run an administrator most needs the second line about. + assert.equal(failed.cooldown_seconds, 0) +}) + +test('every seeded rule carries an hourly ceiling — a module may choose the number, not decline one', () => { + for (const rule of coreRules.EVENT_RULES) { + assert.ok(Number.isInteger(rule.max_sends_per_hour) && rule.max_sends_per_hour > 0, rule.trigger_id) + } +}) + +test('the event rules have their OWN one-shot key, so an upgraded deployment still gets them', () => { + // The Team key is stamped on every deployment that has booted since Phase 6 + // and the news key on every one since Phase 11. Appending to either list would + // seed these on fresh installs only, and on exactly the upgrades that want + // them, never. + const keys = [coreRules.SEEDED_KEY, coreRules.NEWS_SEEDED_KEY, coreRules.EVENT_SEEDED_KEY] + assert.equal(new Set(keys).size, 3) +}) diff --git a/server/test/eventParticipants.test.js b/server/test/eventParticipants.test.js new file mode 100644 index 0000000..e47f635 --- /dev/null +++ b/server/test/eventParticipants.test.js @@ -0,0 +1,193 @@ +// ── Who took part (EVENTS_PLAN.md Phase 10) ──────────────────────────────── +// +// The write half of `event_run_participants`, and it is `events/ledger.js`'s +// twin in every respect that matters — same envelope, same two success shapes, +// same fail-open-on-a-bad-entry posture. The properties worth a test are the +// ones where getting it wrong is silent: +// +// • a module's bad entry is DROPPED, never a retry — a retried step is a +// re-dispatched world write, which is a far worse outcome than one missing +// name on a leaderboard +// • a `userId` is checked rather than coerced, because the column is a foreign +// key into `users` and a character serial that happened to be a real user id +// would attribute somebody's attendance to a stranger +// • one step may not report the same member twice, and the duplicate is NAMED +// rather than silently letting the last one win +// • the classifier carries `participants` on both success shapes, including +// `await: 'human'` — a cue's confirm finishes the step without a second +// dispatch, so that is the only moment its participants can be recorded + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const participants = require('../src/events/participants') +const participantsDb = require('../src/model/events/eventRunParticipants.db') +const { classify } = require('../src/events/dispatch') +const db = require('../src/utils/db') + +after(() => db.close()) + +const ACTION = { id: 'uo.participants.collect' } +const RUN = { id: 42 } +const STEP = { id: 7, phase: 'main' } + +let written +const original = participantsDb.record + +beforeEach(() => { + written = [] + participantsDb.record = async (row) => { + written.push(row) + } +}) + +after(() => { + participantsDb.record = original +}) + +// ── normalise ────────────────────────────────────────────────────────────── + +test('a member key is required and is the only required field', () => { + const ok = participants.normalise({ memberKey: 'darrow' }, ACTION.id) + assert.equal(ok.ok, true) + assert.equal(ok.row.memberKey, 'darrow') + assert.equal(ok.row.userId, null) + assert.equal(ok.row.score, 0) + assert.equal(ok.row.meta, null) + assert.equal(ok.row.joinedAt, null) +}) + +test('every bad shape is refused, and each names what was wrong', () => { + const bad = [ + [null, /not an object/], + ['darrow', /not an object/], + [[], /not an object/], + [{}, /bad memberKey/], + [{ memberKey: '' }, /bad memberKey/], + [{ memberKey: 'x'.repeat(participants.MAX_MEMBER_KEY + 1) }, /bad memberKey/], + [{ memberKey: 'd', userId: 0 }, /bad userId/], + [{ memberKey: 'd', userId: -3 }, /bad userId/], + [{ memberKey: 'd', userId: 1.5 }, /bad userId/], + [{ memberKey: 'd', userId: '4' }, /bad userId/], + [{ memberKey: 'd', score: 'lots' }, /bad score/], + [{ memberKey: 'd', score: Number.NaN }, /bad score/], + [{ memberKey: 'd', score: Infinity }, /bad score/], + [{ memberKey: 'd', joinedAt: 'yesterday' }, /bad joinedAt/], + ] + for (const [entry, pattern] of bad) { + const parsed = participants.normalise(entry, ACTION.id) + assert.equal(parsed.ok, false, `${JSON.stringify(entry)} should be refused`) + assert.match(parsed.reason, pattern) + } +}) + +test('a userId that is a real integer rides through; a serial-shaped string does not', () => { + assert.equal(participants.normalise({ memberKey: 'd', userId: 12 }, ACTION.id).row.userId, 12) + assert.equal(participants.normalise({ memberKey: 'd', userId: '0x4001' }, ACTION.id).ok, false) +}) + +test('a negative score is legal — a game may score downward', () => { + assert.equal(participants.normalise({ memberKey: 'd', score: -40 }, ACTION.id).row.score, -40) +}) + +test('meta that is not an object is dropped rather than refusing the whole participant', () => { + // Decoration on a row whose identity is already valid. Losing an event's + // attendance over a stray string would be the wrong trade. + const parsed = participants.normalise({ memberKey: 'd', meta: 'warrior' }, ACTION.id) + assert.equal(parsed.ok, true) + assert.equal(parsed.row.meta, null) + assert.deepEqual(participants.normalise({ memberKey: 'd', meta: { c: 'mage' } }, ACTION.id).row.meta, { c: 'mage' }) +}) + +// ── recordAnswer ─────────────────────────────────────────────────────────── + +test('nothing reported is not an error and writes nothing', async () => { + assert.deepEqual(await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: undefined }), { + recorded: 0, + rejected: [], + }) + assert.equal(written.length, 0) +}) + +test('a bad entry never fails the step, and the good ones beside it still land', async () => { + const out = await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + participants: [{ memberKey: 'darrow', score: 12 }, { userId: 4 }, { memberKey: 'marisol' }], + }) + assert.equal(out.recorded, 2) + assert.equal(out.rejected.length, 1) + assert.match(out.rejected[0], /bad memberKey/) + assert.deepEqual(written.map((w) => w.memberKey), ['darrow', 'marisol']) +}) + +test('one step reporting the same member twice writes one row and names the duplicate', async () => { + const out = await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + participants: [{ memberKey: 'darrow', score: 12 }, { memberKey: 'darrow', score: 99 }], + }) + assert.equal(out.recorded, 1) + assert.match(out.rejected[0], /twice in one step/) + assert.equal(written.length, 1) + assert.equal(written[0].score, 12) +}) + +test('more participants than one step may report is refused WHOLE, not truncated', async () => { + // Half a leaderboard silently cut is worse than none: the table would look + // complete and be wrong, and nothing downstream could tell. + const many = Array.from({ length: participants.MAX_PER_STEP + 1 }, (_, i) => ({ memberKey: `m${i}` })) + const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: many }) + assert.equal(out.recorded, 0) + assert.equal(written.length, 0) + assert.match(out.rejected[0], /more than the/) +}) + +test('a write that throws is recorded as a rejection rather than becoming the step\'s control flow', async () => { + participantsDb.record = async () => { + throw new Error('deadlock found when trying to get lock') + } + const out = await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + participants: [{ memberKey: 'darrow' }], + }) + assert.equal(out.recorded, 0) + assert.match(out.rejected[0], /deadlock/) +}) + +test('the run id is bound by the caller and never taken from the entry', async () => { + await participants.recordAnswer({ + run: RUN, + step: STEP, + action: ACTION, + // A module cannot record somebody into another run by saying so. + participants: [{ memberKey: 'darrow', runId: 99999 }], + }) + assert.equal(written[0].runId, RUN.id) +}) + +// ── the classifier carries them ──────────────────────────────────────────── + +test('participants ride back on BOTH success shapes, and default to an empty list', () => { + assert.deepEqual(classify({ ok: true, participants: [{ memberKey: 'd' }] }, 'a').participants, [{ memberKey: 'd' }]) + assert.deepEqual( + classify({ ok: true, await: 'human', participants: [{ memberKey: 'd' }] }, 'a').participants, + [{ memberKey: 'd' }], + ) + assert.deepEqual(classify({ ok: true }, 'a').participants, []) + assert.deepEqual(classify({ ok: true, await: 'human' }, 'a').participants, []) +}) + +test('a FAILED envelope carries no participants at all', () => { + // A step that did not succeed did not observe anybody, and an action that + // reported attendance alongside a refusal is reporting something it cannot + // know. There is no `participants` on a failure classification to read. + assert.equal(classify({ ok: false, participants: [{ memberKey: 'd' }] }, 'a').participants, undefined) +}) diff --git a/server/test/eventRunControls.test.js b/server/test/eventRunControls.test.js index 2ae7bb0..0838fa4 100644 --- a/server/test/eventRunControls.test.js +++ b/server/test/eventRunControls.test.js @@ -40,6 +40,9 @@ const gatesDb = require('../src/model/events/eventPhaseGates.db') // a new leg under a model needs a stub in every file that stubs that layer. const resourcesDb = require('../src/model/events/eventRunResources.db') const eventCleanup = require('../src/events/cleanup') +const definitionsDb = require('../src/model/events/eventDefinitions.db') +const participantsDb = require('../src/model/events/eventRunParticipants.db') +const engagementEmit = require('../src/utils/engagementEmit') const db = require('../src/utils/db') after(() => db.close()) @@ -55,10 +58,24 @@ const originals = [ ['gates', gatesDb, { ...gatesDb }], ['resources', resourcesDb, { ...resourcesDb }], ['cleanup', eventCleanup, { ...eventCleanup }], + // Phase 10: `cancel` now announces, and `events/announce.js` reads the + // definition. Left unstubbed every cancel here would wait out the dead-port + // pool. Stubbed rather than silenced, so the announce path runs for real and + // `store.emits` can assert it fired after the guarded transition and not + // before it. + ['definitions', definitionsDb, { ...definitionsDb }], + ['participants', participantsDb, { ...participantsDb }], + ['emit', engagementEmit, { ...engagementEmit }], ] function installStubs() { - store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, nextStepId: 1, nextGateId: 1 } + store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, emits: [], nextStepId: 1, nextGateId: 1 } + definitionsDb.getById = async (id) => ({ id, title: `definition ${id}`, summary: null, series_name: null, timezone: 'UTC' }) + participantsDb.countForRun = async () => 0 + engagementEmit.emit = (owner, triggerId, envelope) => { + store.emits.push({ owner, triggerId, envelope }) + return { ok: true } + } const snap = (o) => ({ ...o }) runsDb.setCleanupStatus = async (id, to, from = null) => { @@ -591,6 +608,18 @@ test('an empty reason is stored as NULL rather than as an empty string', async ( // ── cancel decides what happens to the world (Phase 8) ───────────────────── +/** The `run.status` line for one run — Phase 10 stopped it being the last one. */ +const statusLine = (id) => store.log.filter((l) => l.runId === id && l.kind === 'run.status').at(-1) + +test('a refused cancel announces nothing at all', async () => { + // The emit is after the guarded transition, so the loser of a race between two + // moderators pressing cancel has already returned a 409 and said nothing. + const id = seedRun({ status: 'completed', steps: [] }) + const refused = await controls.cancel(id, { reason: 'too late' }, ACTOR) + assert.equal(refused.ok, false) + assert.deepEqual(store.emits, []) +}) + test('cancel gives back what the run took, by default and without waiting for it', async () => { // The teardown is the runner cleanup leg over TERMINAL runs, not this request. // Two reasons, and both are why the control answers at once: a cancel pressed @@ -609,7 +638,15 @@ test('cancel gives back what the run took, by default and without waiting for it // Still `pending`, which is what the leg looks for. The run is terminal the // moment this returns, so the very next tick picks its ledger up. assert.equal(runRow(id).cleanup_status, 'pending') - assert.equal(store.log.at(-1).detail.cleanup, true) + // The status line, found by kind rather than by being last: Phase 10 put an + // `announcement.emitted` line after it, because the announcement genuinely + // happens after the guarded transition. + assert.equal(statusLine(id).detail.cleanup, true) + + // …and the run announced its own cancellation, with the operator's reason and + // not the diagnostic string that ends up in `last_error`. + assert.deepEqual(store.emits.map((e) => e.triggerId), ['event.run.cancelled']) + assert.equal(store.emits[0].envelope.data.reason, 'called off') }) test('cancel WITHOUT cleanup is admin-only, even though the route is wider', async () => { @@ -646,8 +683,8 @@ test('cancel without cleanup leaves the world changes up, and says so on the run assert.equal(result.ok, true) assert.equal(result.cleanup, false) assert.equal(runRow(id).cleanup_status, 'incomplete') - assert.equal(store.log.at(-1).detail.cleanup, false) - assert.equal(store.log.at(-1).detail.by, ACTOR) + assert.equal(statusLine(id).detail.cleanup, false) + assert.equal(statusLine(id).detail.by, ACTOR) }) test('a run that recorded nothing is unaffected by either flag', async () => { diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js index e14efa4..05f6909 100644 --- a/server/test/eventRunner.test.js +++ b/server/test/eventRunner.test.js @@ -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')]) diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js index 0b77599..e5706bf 100644 --- a/server/test/eventsAdmin.test.js +++ b/server/test/eventsAdmin.test.js @@ -49,6 +49,7 @@ const budgetDb = require('../src/model/events/eventRunBudget.db') // one missing stub here cost the run detail test ten seconds and said nothing // about the route it was testing. const resourcesDb = require('../src/model/events/eventRunResources.db') +const participantsDb = require('../src/model/events/eventRunParticipants.db') const seriesDb = require('../src/model/events/eventSeries.db') const gatesDb = require('../src/model/events/eventPhaseGates.db') const activity = require('../src/model/activity/activity.model') @@ -71,6 +72,7 @@ for (const [name, mod] of [ ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb], + ['participantsDb', participantsDb], ['activity', activity], ]) { originals[name] = { mod, fns: { ...mod } } @@ -353,6 +355,13 @@ function installStubs() { resourcesDb.forRun = async (runId) => store.resources.filter((r) => Number(r.run_id) === Number(runId)) + + // Phase 10: a run's detail now carries its participants. Unstubbed this is the + // ten-second dead-port wait, for the sixth time in this feature — and it would + // 500 the run console rather than saying anything about the route. + store.participants = [] + participantsDb.listForRun = async (runId) => + store.participants.filter((p) => Number(p.run_id) === Number(runId)) } // ── Fixtures ─────────────────────────────────────────────────────────────── @@ -427,7 +436,10 @@ test('the catalog serves the registry, callables stripped, with its vocabularies assert.equal(res.statusCode, 200) assert.deepEqual( res.body.actions.map((a) => a.id), - ['core.announce', 'core.wait', 'core.cue', 'core.lease'], + // Phase 10's two are the integrations (EVENTS.md §J): `core.announce.post` + // sends an article through the announce legs rather than a line, and + // `core.results.publish` ranks and stamps the run's participants. + ['core.announce', 'core.wait', 'core.cue', 'core.lease', 'core.announce.post', 'core.results.publish'], ) for (const action of res.body.actions) assert.equal(action.perform, undefined) assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible']) @@ -446,7 +458,7 @@ test('the catalog serves the registry, callables stripped, with its vocabularies // their own. assert.deepEqual( res.body.optionSources.map((s) => s.id), - ['core.options.legs', 'core.options.leases'], + ['core.options.legs', 'core.options.leases', 'core.options.posts'], ) for (const s of res.body.optionSources) assert.equal(s.resolve, undefined) }) @@ -839,7 +851,19 @@ test('the board serves every registered action with its risk-class default, and assert.equal(res.statusCode, 200) const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a])) - assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.lease', 'core.wait']) + assert.deepEqual(Object.keys(byId).sort(), [ + 'core.announce', + 'core.announce.post', + 'core.cue', + 'core.lease', + 'core.results.publish', + 'core.wait', + ]) + // `core.results.publish` is `inspect`, so like `core.wait` it arrives ENABLED: + // nothing in the game world changes and nobody is messaged, and an author + // should be able to place it without an admin first visiting this screen. + assert.equal(byId['core.results.publish'].enabled, true) + assert.equal(byId['core.results.publish'].changesWorld, false) // And `core.lease` is the one core action the default-off rule bites: it is // `change`, so a fresh deployment cannot borrow a value until an admin says so. // §K's sentence, applied to core's own verb rather than only to a module's. diff --git a/server/test/moduleRegistries.test.js b/server/test/moduleRegistries.test.js index e659321..969d7fa 100644 --- a/server/test/moduleRegistries.test.js +++ b/server/test/moduleRegistries.test.js @@ -53,12 +53,23 @@ test('registerCore registers exactly what core owns, and nothing else', () => { // supplies who is in a Team, but who may be told about it is the access // resolver's answer. Asserted as an exact list so a shard-content stream // creeping back into core's registration fails here rather than shipping. + // + // **`event.run.started` joined them in Events Phase 10, and it is the one + // event trigger that is also a stream** (org lead, 2026-09-04). A stream is a + // PUSH toggle: `publishToUsers` joins `notification_subscriptions`, which is + // only ever written for an id the preferences screen offered push for, and + // that screen offers push only for a registered stream. So a rule naming + // `push` on a trigger-only id publishes a tickle to nobody while the send log + // records it sent — which is what the live walk found. Push is the channel + // that says *now*, so the one lifecycle moment worth waking a phone for gets + // it and the other six do not. assert.deepEqual(registries.allStreams().map((s) => s.id), [ 'news.post', 'team.member.joined', 'team.leadership.changed', 'team.forum.post', 'team.announcement', + 'event.run.started', ]) assert.deepEqual(registries.announceLegIds(), ['discord']) assert.equal(registries.slotFilledBy('admin.users.detail'), null)