Merge pull request 'feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)' (#192) from feature/events-phase-10 into edge
Reviewed-on: #192
This commit is contained in:
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 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 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
Who took part
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
{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{' '}
|
||||
<code style={{ fontSize: '0.78rem' }}>core.results.publish</code> step in the event
|
||||
itself.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => (
|
||||
<tr key={p.memberKey}>
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', width: 34, textAlign: 'right' }}>
|
||||
{p.rank ?? ''}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{p.memberKey}</code>
|
||||
</td>
|
||||
{/* 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. */}
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{p.userId ? `account ${p.userId}` : 'no linked account'}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>{p.score}</td>
|
||||
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
||||
{clock(p.joinedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{participants.length > PARTICIPANTS_SHOWN && (
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.78rem' }}>
|
||||
and {participants.length - PARTICIPANTS_SHOWN} more.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Waiting on a person ── */}
|
||||
{parked.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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/<slug>`, 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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
225
server/src/events/announce.js
Normal file
225
server/src/events/announce.js
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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 || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
162
server/src/events/participants.js
Normal file
162
server/src/events/participants.js
Normal file
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
119
server/src/model/events/eventRunParticipants.db.js
Normal file
119
server/src/model/events/eventRunParticipants.db.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// ── event_run_participants — SQL only ──────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of
|
||||
// §D's core tables: who took part in a run, and how well.
|
||||
//
|
||||
// **Core writes this table and never sources it.** A `member_key` is
|
||||
// module-opaque, exactly as a resource's `ref` is — core cannot map a character
|
||||
// name onto a user row and must not try, because that mapping is one game's
|
||||
// (`shard_links`, for module-uo) and would be that game compiled into core. A
|
||||
// module that knows both halves reports both; core stores what it is told.
|
||||
//
|
||||
// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step
|
||||
// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried
|
||||
// collect that duplicated its rows would double a leaderboard. It is the same
|
||||
// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table
|
||||
// along, with the difference that a re-report may carry a BETTER score and must
|
||||
// win rather than be ignored.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already
|
||||
// arrives as a JS number; the coercion is belt to that braces and costs nothing.
|
||||
// `meta` is hydrated for the reason a resource's payload is: opaque to core, but
|
||||
// every caller wants the object rather than the string the driver returns.
|
||||
const hydrate = (row) =>
|
||||
row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) }
|
||||
|
||||
/**
|
||||
* Record one participant, or update the one already recorded.
|
||||
*
|
||||
* **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is
|
||||
* the point of the column: it is when this participant first appeared, and a
|
||||
* second report — a later collect, a corrected score — must not rewrite it. The
|
||||
* same applies to `rank_at`, which is not touched here at all: ranking is
|
||||
* `core.results.publish`'s job and a re-report between two publications must not
|
||||
* silently invent a rank nobody computed.
|
||||
*
|
||||
* `user_id` DOES move on a re-report, deliberately: a player who linked their
|
||||
* website account between two collects should stop being anonymous, and the
|
||||
* module is the only thing that can know they did.
|
||||
*
|
||||
* **It answers nothing, and the reason is a trap worth naming.** The obvious
|
||||
* return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an
|
||||
* update. That is true only without `CLIENT_FOUND_ROWS`, and this connector
|
||||
* sends it: with it, a re-report whose values are identical also answers 1, so
|
||||
* the flag would report every idempotent retry as a fresh participant. The
|
||||
* caller wants "how many were reported" anyway, which it already knows from the
|
||||
* length of its own list.
|
||||
*/
|
||||
async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) {
|
||||
await query(
|
||||
`INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
score = VALUES(score),
|
||||
meta = VALUES(meta)`,
|
||||
[runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt],
|
||||
)
|
||||
}
|
||||
|
||||
/** One run's participants, best first. The results table, and the console's. */
|
||||
async function listForRun(runId, limit = 500) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_participants
|
||||
WHERE run_id = ?
|
||||
ORDER BY score DESC, joined_at ASC, id ASC
|
||||
LIMIT ?`,
|
||||
[runId, limit],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** How many the run has. Its own query because the trigger payload needs only this. */
|
||||
async function countForRun(runId) {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Number every participant of one run by score, best first.
|
||||
*
|
||||
* **One statement, and it has to be one.** The obvious form — `SET @rk := 0`
|
||||
* followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is
|
||||
* wrong here in a way that would have passed every test that did not run twice
|
||||
* concurrently: `query()` takes a connection from the pool per call and releases
|
||||
* it, so the session variable is set on one connection and read on whichever the
|
||||
* second call happens to get. A window function needs no session state at all.
|
||||
*
|
||||
* **The ordering is total.** `score DESC` alone leaves ties in whatever order the
|
||||
* engine felt like, so two publications of the same run would hand out different
|
||||
* ranks to the same two people; `joined_at` then `id` breaks every tie the same
|
||||
* way every time, which is what makes re-publishing idempotent rather than a
|
||||
* reshuffle.
|
||||
*
|
||||
* Ties share nothing — two people on the same score get consecutive ranks rather
|
||||
* than a dense or competition ranking. That is a presentation decision belonging
|
||||
* to whatever renders the table; what this owes is a stable number.
|
||||
*/
|
||||
async function rankRun(runId) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_participants p
|
||||
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk
|
||||
FROM event_run_participants
|
||||
WHERE run_id = ?) r ON r.id = p.id
|
||||
SET p.rank_at = r.rk`,
|
||||
[runId],
|
||||
)
|
||||
// The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather
|
||||
// than rows changed — which is the number wanted here. Re-publishing a run
|
||||
// whose ranks are already correct answers "12 ranked", not "0".
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, listForRun, countForRun, rankRun }
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,19 @@ async function listPublished(category) {
|
||||
)
|
||||
}
|
||||
|
||||
// Every published post, across categories, newest first — the option source
|
||||
// behind `core.announce.post`'s `postId` param (EVENTS.md §F, Phase 10). Its own
|
||||
// query rather than a loop over `listPublished` because an authoring dropdown
|
||||
// wants one bounded, ordered list and needs neither the body nor the excerpt: a
|
||||
// hundred posts' bodies would be a megabyte of HTML sent to draw a `<select>`.
|
||||
async function listPublishedForOptions(limit = 200) {
|
||||
const n = Math.min(Math.max(Number(limit) || 200, 1), 500)
|
||||
return query(
|
||||
'SELECT id, category, title FROM posts WHERE published = 1 ' +
|
||||
`ORDER BY COALESCE(published_at, created_at) DESC, id DESC LIMIT ${n}`,
|
||||
)
|
||||
}
|
||||
|
||||
// All posts for a category (admin), newest first.
|
||||
async function listAll(category) {
|
||||
if (category) {
|
||||
@@ -74,6 +87,7 @@ async function countByCategory() {
|
||||
|
||||
module.exports = {
|
||||
listPublished,
|
||||
listPublishedForOptions,
|
||||
listAll,
|
||||
findById,
|
||||
findPublished,
|
||||
|
||||
@@ -92,6 +92,10 @@ const shapeRun = (r) => ({
|
||||
rehearsal: r.rehearsal,
|
||||
startedAt: r.started_at,
|
||||
endedAt: r.ended_at,
|
||||
// When the results table was ranked and published (Phase 10). On the LIST as
|
||||
// well as the console, because "which of last month's events still have no
|
||||
// published results" is a question about a list.
|
||||
resultsPublishedAt: r.results_published_at,
|
||||
lastError: r.last_error,
|
||||
createdAt: r.created_at,
|
||||
// How many steps are parked on a human. Derived, not a column, and surfaced on
|
||||
@@ -320,6 +324,11 @@ exports.getRun = async (req, res) => {
|
||||
// question with two halves, and a list of only the failures answers neither.
|
||||
resources: found.resources,
|
||||
unresolvedResources: found.unresolvedResources,
|
||||
// Who took part, best first (Phase 10). `rank` is null on every row until
|
||||
// `core.results.publish` has ranked them, which is what lets the console
|
||||
// show a collected-but-unpublished run as exactly that rather than
|
||||
// inventing an ordering nobody settled.
|
||||
participants: found.participants,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -209,9 +209,9 @@ eventsRouter.get(
|
||||
'/runs/:runId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder's own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list.'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter and the resource ledger', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.getRun,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// silently loses a variable is a template that silently renders `undefined`.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
const engine = require('../engagement/engine')
|
||||
const eventGates = require('../events/gates')
|
||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||
@@ -166,7 +167,7 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
|
||||
}
|
||||
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds } = envelope || {}
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds, ceiling } = envelope || {}
|
||||
|
||||
const payload = validatePayload(declaration, data)
|
||||
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
|
||||
@@ -226,6 +227,33 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
resolvedRecipients = [...new Set(recipientUserIds)]
|
||||
}
|
||||
|
||||
// **A ceiling this ONE firing may not exceed** (MODULE_API 1.11.0, EVENTS.md
|
||||
// §I, org lead 2026-09-04). A trigger's declared ceiling is a property of the
|
||||
// KIND of event; this is a property of the occasion, and the two are different
|
||||
// questions. The case that forced it is the rehearsal: §I promises an event
|
||||
// can be "run for real with announcements ceilinged to `staff`", and a
|
||||
// rehearsal fires exactly the same trigger as the real thing — so without a
|
||||
// per-firing bound, rehearsing a published event mails every subscriber it.
|
||||
//
|
||||
// **It only ever NARROWS.** The send-time G24 gate takes
|
||||
// `meet(declared, emitted)`, so an emitter can tighten a ceiling and can never
|
||||
// loosen one, and an emitter that names something incomparable with the
|
||||
// declaration — `owner` against a declared `staff` — meets to null and the gate
|
||||
// refuses every rule rather than guessing which branch was meant. That is the
|
||||
// lattice's existing posture (`segments.js` §5.1a rule 3), reused rather than
|
||||
// re-argued.
|
||||
//
|
||||
// Not stored on the outbox row, deliberately: by the time a row exists the gate
|
||||
// has already run, and a second copy of the bound would be a second thing that
|
||||
// can disagree with the declaration it was checked against.
|
||||
let resolvedCeiling = null
|
||||
if (ceiling !== undefined && ceiling !== null) {
|
||||
if (!ceilings.isCeiling(ceiling)) {
|
||||
return fail(`ceiling must be one of ${ceilings.CEILINGS.join(', ')}`)
|
||||
}
|
||||
resolvedCeiling = ceiling
|
||||
}
|
||||
|
||||
if (dedupeKey !== undefined && dedupeKey !== null) {
|
||||
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
|
||||
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
|
||||
@@ -247,6 +275,7 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
|
||||
scopeKey: resolvedScope,
|
||||
recipientUserIds: resolvedRecipients,
|
||||
ceiling: resolvedCeiling,
|
||||
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
|
||||
occurredAt: at.toISOString(),
|
||||
data: payload.data,
|
||||
|
||||
@@ -79,6 +79,8 @@ const spec = require('../events/spec')
|
||||
const registries = require('../modules/registries')
|
||||
const { dispatchStep } = require('../events/dispatch')
|
||||
const ledger = require('../events/ledger')
|
||||
const participants = require('../events/participants')
|
||||
const announce = require('../events/announce')
|
||||
const cleanup = require('../events/cleanup')
|
||||
const authorize = require('../events/authorize')
|
||||
const log = require('./logger')('event-runner')
|
||||
@@ -197,6 +199,7 @@ async function applyFailure(run, step, error, { status = 'failed', kind = 'step.
|
||||
phase: step.phase,
|
||||
detail: { to: 'failed', because: step.action_id, cancelledSteps: cancelled },
|
||||
})
|
||||
await announce.runFailed({ ...run, current_phase: step.phase }, error)
|
||||
return 'stop'
|
||||
}
|
||||
|
||||
@@ -333,6 +336,31 @@ async function drainStep(run, step, now, carry = {}) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// **Who it happened to, recorded beside what it did** (Phase 10). Same two
|
||||
// success shapes, same posture: reported on the envelope, written here,
|
||||
// never allowed to fail the step. Its own log line rather than a field on
|
||||
// `resource.recorded` because the two answer different questions and a run
|
||||
// very often has one without the other.
|
||||
const attended = await participants.recordAnswer({
|
||||
run,
|
||||
step,
|
||||
action,
|
||||
participants: result.participants,
|
||||
})
|
||||
if (attended.recorded > 0 || attended.rejected.length > 0) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
kind: 'participants.recorded',
|
||||
phase: step.phase,
|
||||
detail: {
|
||||
action: step.action_id,
|
||||
recorded: attended.recorded,
|
||||
...(attended.rejected.length ? { rejected: attended.rejected } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (result.outcome === 'parked') {
|
||||
@@ -502,6 +530,7 @@ async function advanceRun(run, now) {
|
||||
error: 'the pinned version has no phases',
|
||||
})
|
||||
await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'failed', because: 'pinned version has no phases' } })
|
||||
await announce.runFailed(run, 'the pinned version has no phases')
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
@@ -518,6 +547,12 @@ async function advanceRun(run, now) {
|
||||
if (!(await runsDb.transition(run.id, 'starting', 'running', { phase: first.key }))) return 'taken'
|
||||
phaseKey = first.key
|
||||
await logDb.write({ runId: run.id, kind: 'run.status', phase: first.key, detail: { from: 'starting', to: 'running' } })
|
||||
// **After the guarded transition, never before it** (Phase 10). The
|
||||
// transition is the compare-and-set that decides which tick owns this run;
|
||||
// announcing on the losing side of it would mail the same "starting now" for
|
||||
// every process that tried. The same rule holds at each of the six emits
|
||||
// below, and it is why none of them sits beside a `materialisePhase` call.
|
||||
await announce.runStarted({ ...run, current_phase: first.key }, now)
|
||||
}
|
||||
|
||||
if (run.status === 'ending') {
|
||||
@@ -528,6 +563,11 @@ async function advanceRun(run, now) {
|
||||
// the second call site the leg exists to avoid.
|
||||
await runsDb.transition(run.id, 'ending', 'completed')
|
||||
await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } })
|
||||
// The recovery path: a run that reached the wind-down and lost its process.
|
||||
// `run.ending` is deliberately NOT re-emitted here — the tick that put this
|
||||
// run into `ending` already announced it, and the whole reason this branch
|
||||
// exists is that the process died afterwards.
|
||||
await announce.runCompleted(run, now)
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
@@ -546,8 +586,10 @@ async function advanceRun(run, now) {
|
||||
|
||||
const phaseIndex = phases.findIndex((p) => p.key === phaseKey)
|
||||
if (phaseIndex < 0) {
|
||||
await runsDb.transition(run.id, ['running'], 'failed', { error: `phase "${phaseKey}" is not in the pinned version` })
|
||||
const error = `phase "${phaseKey}" is not in the pinned version`
|
||||
await runsDb.transition(run.id, ['running'], 'failed', { error })
|
||||
await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'failed', because: `unknown phase "${phaseKey}"` } })
|
||||
await announce.runFailed({ ...run, current_phase: phaseKey }, error)
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
@@ -605,8 +647,15 @@ async function advanceRun(run, now) {
|
||||
// tick and takes the run as it now is.
|
||||
if (!(await runsDb.transition(run.id, 'running', 'ending'))) return 'taken'
|
||||
await logDb.write({ runId: run.id, kind: 'run.status', phase: phaseKey, detail: { from: 'running', to: 'ending' } })
|
||||
await announce.runEnding({ ...run, current_phase: phaseKey })
|
||||
await runsDb.transition(run.id, 'ending', 'completed')
|
||||
await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } })
|
||||
// **`ending` and `completed` in the same tick, so both fire moments apart**,
|
||||
// and that is honest rather than tidy: §E's `ending` is what a claim sets,
|
||||
// and a run passes straight through it. A deployment that wants only one
|
||||
// of the two writes one rule; the pair exists because a run whose teardown
|
||||
// is slow does linger there, and an operator watching one wants to know.
|
||||
await announce.runCompleted({ ...run, current_phase: phaseKey }, now)
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
@@ -624,6 +673,15 @@ async function advanceRun(run, now) {
|
||||
if (!(await runsDb.transition(run.id, 'running', 'running', { phase: next.key }))) return 'taken'
|
||||
phaseKey = next.key
|
||||
await logDb.write({ runId: run.id, kind: 'phase.entered', phase: next.key, detail: { steps: (next.steps || []).length } })
|
||||
// The FIRST phase does not fire this — `event.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.
|
||||
await announce.phaseChanged({ ...run, current_phase: next.key }, {
|
||||
phase: next.key,
|
||||
label: next.label,
|
||||
index: phaseIndex + 1,
|
||||
count: phases.length,
|
||||
})
|
||||
}
|
||||
|
||||
return 'bounded' // more to do; the next tick picks it up
|
||||
@@ -759,6 +817,13 @@ async function expandSchedules(now) {
|
||||
}
|
||||
if (!result.created) continue
|
||||
created += 1
|
||||
// **On materialisation, not on publish.** An occurrence is the thing a
|
||||
// player can be told about — it has a date — and a definition does not.
|
||||
// It fires up to `HORIZON_DAYS` ahead of the event, which is what makes
|
||||
// it the "save the date" trigger rather than the "starting now" one; a
|
||||
// rule wanting a reminder closer to the hour is a `delay_seconds` on
|
||||
// this or a rule on `event.run.started`.
|
||||
await announce.runScheduled(result.run)
|
||||
if (occurrence.adjusted) {
|
||||
// Why the clock reads oddly, recorded where an operator will look for
|
||||
// it rather than left to be rediscovered at 3am on the last Sunday in
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Runic Gateway API",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)."
|
||||
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_rig`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -4124,7 +4124,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
|
||||
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder",
|
||||
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -4137,7 +4137,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The run, its steps, the status counts, the phase gates, the cap meter and the resource ledger",
|
||||
"description": "The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -4181,6 +4181,13 @@
|
||||
},
|
||||
"unresolvedResources": {
|
||||
"type": "integer"
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17877,7 +17884,7 @@
|
||||
"cookieAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "rg_token",
|
||||
"name": "rg_rig",
|
||||
"description": "Session JWT set as an httpOnly cookie by POST /api/v1/auth/login."
|
||||
},
|
||||
"bearerAuth": {
|
||||
|
||||
@@ -701,6 +701,48 @@ test('the ceiling is re-checked at SEND time, so a module narrowing its declarat
|
||||
assert.equal(after2.enqueued, 0)
|
||||
})
|
||||
|
||||
test('a FIRING may narrow the ceiling, and every rule wider than that is refused', async () => {
|
||||
// EVENTS.md §I, and the case that forced it: a rehearsal fires exactly the
|
||||
// same trigger as the real thing, so without a per-firing bound, rehearsing a
|
||||
// published event mails every subscriber it. The declaration is a property of
|
||||
// the KIND of event; this is a property of the occasion.
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||
addUser(11)
|
||||
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||
optIn(11, 'uo.house.idoc_warning', 'email')
|
||||
addRule({ audience: 'authenticated' })
|
||||
|
||||
const wide = await engine.dispatch(event(), T0)
|
||||
assert.equal(wide.enqueued, 2)
|
||||
|
||||
const narrowed = await engine.dispatch(event({ subject: 'house-9', ceiling: 'staff' }), later(1000))
|
||||
assert.equal(narrowed.enqueued, 0)
|
||||
})
|
||||
|
||||
test('a firing may only ever NARROW — a wider ceiling than the declaration changes nothing', async () => {
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'owner', audience: 'owner' })
|
||||
addRule({ audience: 'owner' })
|
||||
|
||||
// `everyone` is the widest value the lattice has. `meet(owner, everyone)` is
|
||||
// `owner`, so the declaration still governs and the rule still fires.
|
||||
const out = await engine.dispatch(event({ ceiling: 'everyone' }), T0)
|
||||
assert.equal(out.enqueued, 1)
|
||||
})
|
||||
|
||||
test('two INCOMPARABLE ceilings meet to nothing and the gate refuses rather than guessing', async () => {
|
||||
// `owner` and `staff` have no common descendant — "fewer people" is not "less
|
||||
// exposure", which is the whole argument `modules/ceilings.js` is built on.
|
||||
// Picking one would be the guess §5.1a rule 3 exists to refuse.
|
||||
registries._reset()
|
||||
registerUoTrigger({ ceiling: 'owner', audience: 'owner' })
|
||||
addRule({ audience: 'owner' })
|
||||
|
||||
const out = await engine.dispatch(event({ ceiling: 'staff' }), T0)
|
||||
assert.equal(out.enqueued, 0)
|
||||
})
|
||||
|
||||
test('a rule for an unregistered trigger is dormant, not deleted and not an error', async () => {
|
||||
const rule = addRule({ trigger_id: 'uo.gone.away' })
|
||||
const listed = await rules.listAnnotated()
|
||||
|
||||
@@ -76,15 +76,50 @@ function register(owner, fn) {
|
||||
|
||||
// ── Core's own declarations ────────────────────────────────────────────────
|
||||
|
||||
test('core registers its five triggers, and they are the five stream ids', () => {
|
||||
test('every core STREAM is also a trigger, and one namespace holds both', () => {
|
||||
// §7.2's one namespace, and what Events Phase 10 taught it. Until then the two
|
||||
// sets were identical and the test asserted that; they are not identical any
|
||||
// more and were never required to be. **A stream is a push toggle and a
|
||||
// trigger is a payload contract**, and the rule is containment in one
|
||||
// direction only: an id that can be pushed must have something to say, so
|
||||
// every stream is a trigger — while a trigger that is not a stream is simply
|
||||
// one nothing wakes a phone for, which is six of the seven event triggers.
|
||||
//
|
||||
// The direction matters. A STREAM with no trigger would be a push toggle for
|
||||
// an event no rule can fire; a trigger with no stream is mail and an inbox row
|
||||
// and no tickle, which is a complete and useful notification.
|
||||
registries.registerCore()
|
||||
const triggerIds = registries.allTriggers().map((t) => t.id).sort()
|
||||
const streamIds = registries.allStreams().map((s) => s.id).sort()
|
||||
assert.deepEqual(triggerIds, streamIds)
|
||||
assert.deepEqual(streamIds.filter((id) => !triggerIds.includes(id)), [])
|
||||
assert.deepEqual(triggerIds, [
|
||||
'event.phase.changed', 'event.run.cancelled', 'event.run.completed',
|
||||
'event.run.ending', 'event.run.failed', 'event.run.scheduled',
|
||||
'event.run.started',
|
||||
'news.post', 'team.announcement', 'team.forum.post',
|
||||
'team.leadership.changed', 'team.member.joined',
|
||||
])
|
||||
// The one event id that is BOTH (org lead, 2026-09-04). Push is the channel
|
||||
// that says "now", and this is the only lifecycle moment worth waking a phone
|
||||
// for. A rule naming `push` on any of the other six would enqueue a tickle
|
||||
// nobody can subscribe to — the defect the live walk found.
|
||||
assert.deepEqual(streamIds, [
|
||||
'event.run.started',
|
||||
'news.post', 'team.announcement', 'team.forum.post',
|
||||
'team.leadership.changed', 'team.member.joined',
|
||||
])
|
||||
})
|
||||
|
||||
test('only a stream offers the push channel, which is why run.started is one', () => {
|
||||
// eslint-disable-next-line global-require
|
||||
const prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model')
|
||||
registries.registerCore()
|
||||
const byId = Object.fromEntries(prefs.catalog({ role: 'admin' }).map((i) => [i.id, i]))
|
||||
assert.ok(byId['event.run.started'].channels.includes('push'))
|
||||
assert.ok(!byId['event.run.completed'].channels.includes('push'))
|
||||
// …and a trigger-only id still reaches a person on the two content channels.
|
||||
assert.ok(byId['event.run.completed'].channels.includes('email'))
|
||||
assert.ok(byId['event.run.completed'].channels.includes('inapp'))
|
||||
})
|
||||
|
||||
test('the four Team triggers ceiling at members — a private forum excerpt cannot be widened', () => {
|
||||
@@ -413,7 +448,8 @@ test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling
|
||||
registries.registerCore()
|
||||
const res = mockRes()
|
||||
ctrl.listTriggers({}, res)
|
||||
assert.equal(res.body.triggers.length, 5)
|
||||
// Five, plus Events Phase 10's seven.
|
||||
assert.equal(res.body.triggers.length, 12)
|
||||
const news = res.body.triggers.find((t) => t.id === 'news.post')
|
||||
assert.equal(news.owner, 'core')
|
||||
assert.ok(news.variables.some((v) => v.name === 'title' && v.example))
|
||||
|
||||
@@ -44,7 +44,7 @@ const register = (owner, entries) => {
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
test('core registers its four actions on every boot', () => {
|
||||
test('core registers its six actions on every boot', () => {
|
||||
registries.registerCore()
|
||||
const ids = registries.allEventActions().map((a) => a.id)
|
||||
// `core.lease` joined the three in Phase 8, and it is the only one of the four
|
||||
@@ -52,7 +52,20 @@ test('core registers its four actions on every boot', () => {
|
||||
// module's: §F puts the duration bound and the two-events-one-target conflict
|
||||
// check on core's side of the seam, and a lease verb per module would be that
|
||||
// bound re-implemented once per module and advisory everywhere.
|
||||
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue', 'core.lease'])
|
||||
//
|
||||
// Phase 10 added the last two, and they are the integrations (§J):
|
||||
// `core.announce.post` sends an ARTICLE through the announce legs rather than
|
||||
// a line, and `core.results.publish` is what makes §F's "publish results"
|
||||
// literal. Six is now the whole of what an event can do on a deployment with
|
||||
// no game module installed at all.
|
||||
assert.deepEqual(ids, [
|
||||
'core.announce',
|
||||
'core.wait',
|
||||
'core.cue',
|
||||
'core.lease',
|
||||
'core.announce.post',
|
||||
'core.results.publish',
|
||||
])
|
||||
assert.equal(ids.length, coreEventActions.ACTIONS.length)
|
||||
})
|
||||
|
||||
@@ -248,7 +261,7 @@ test('a whole batch is refused or taken, never half', () => {
|
||||
|
||||
test('_reset() hands the process back', () => {
|
||||
registries.registerCore()
|
||||
assert.equal(registries.allEventActions().length, 4)
|
||||
assert.equal(registries.allEventActions().length, 6)
|
||||
registries._reset()
|
||||
assert.equal(registries.allEventActions().length, 0)
|
||||
assert.equal(registries.isEventAction('core.wait'), false)
|
||||
@@ -378,6 +391,10 @@ test('an option source needs a resolver, and core registers one of its own', ()
|
||||
// param would otherwise be a free-text box whose typo is caught at dispatch,
|
||||
// mid-run — and the leases are already a registry with labels in them.
|
||||
'core.options.leases',
|
||||
// Phase 10's, and the first core source that reaches a table. It is allowed
|
||||
// to because of WHEN a source resolves: on its own request, on a booted
|
||||
// server, rather than at `register()` time under a dead pool.
|
||||
'core.options.posts',
|
||||
])
|
||||
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
|
||||
assert.equal(leg.source, 'core.options.legs')
|
||||
|
||||
308
server/test/eventAnnounce.test.js
Normal file
308
server/test/eventAnnounce.test.js
Normal file
@@ -0,0 +1,308 @@
|
||||
// ── The lifecycle announcements (EVENTS_PLAN.md Phase 10) ──────────────────
|
||||
//
|
||||
// §J's "Events owns none of the delivery", made testable. What this file
|
||||
// asserts is that `events/announce.js` says the right thing and then stops —
|
||||
// nothing here knows about email, subscribers, templates or cooldowns, and the
|
||||
// evidence for that is that every test below stubs `engagementEmit.emit` and
|
||||
// reads what was handed to it.
|
||||
//
|
||||
// Three properties, and each is a thing that would be silent if it broke:
|
||||
//
|
||||
// • **a rehearsal narrows the ceiling** (§I). The same triggers fire, so the
|
||||
// announce steps are genuinely rehearsed — and `ceiling: 'staff'` on the
|
||||
// envelope is what stops a rehearsal of a published event mailing every
|
||||
// subscriber it. This is the one property in the phase with a blast radius.
|
||||
// • **the cooldown subject is the RUN.** Keyed on the user, `phase.changed`
|
||||
// would mean "at most one phase of at most one event an hour" and would
|
||||
// silently swallow the second wave of an invasion.
|
||||
// • **a read that fails does not fail the run.** Every caller is a transition
|
||||
// in the runner, and a run must not fail to start because the row that says
|
||||
// what it is called could not be read.
|
||||
//
|
||||
// The trigger declarations get their own assertions here rather than in
|
||||
// `engagementTriggers.test.js`, because what is being checked is not that the
|
||||
// registry accepts them — it does, for anything well-formed — but the two
|
||||
// EVENTS.md decisions they encode: which ceiling each sits at, and that the six
|
||||
// public ones declare no `url` variable while there is no public page to point
|
||||
// at (the `news.post` mistake, not repeated).
|
||||
|
||||
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 announce = require('../src/events/announce')
|
||||
const engagementEmit = require('../src/utils/engagementEmit')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||
const logDb = require('../src/model/events/eventRunLog.db')
|
||||
const { TRIGGERS } = require('../src/config/coreTriggers')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const DEFINITION = {
|
||||
id: 3,
|
||||
title: 'The Yew Invasion',
|
||||
summary: 'Orcish warbands are massing north of Yew.',
|
||||
series_name: 'The Yew Campaign',
|
||||
timezone: 'America/New_York',
|
||||
}
|
||||
|
||||
const RUN = {
|
||||
id: 3692,
|
||||
definition_id: 3,
|
||||
timezone: 'America/New_York',
|
||||
scheduled_for: new Date('2026-09-13T00:00:00.000Z'),
|
||||
started_at: new Date('2026-09-13T00:00:14.000Z'),
|
||||
current_phase: 'assault',
|
||||
rehearsal: 0,
|
||||
last_error: null,
|
||||
}
|
||||
|
||||
let emitted
|
||||
let lines
|
||||
const originals = {
|
||||
emit: engagementEmit.emit,
|
||||
getById: definitionsDb.getById,
|
||||
count: participantsDb.countForRun,
|
||||
write: logDb.write,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
emitted = []
|
||||
lines = []
|
||||
engagementEmit.emit = (owner, triggerId, envelope) => {
|
||||
emitted.push({ owner, triggerId, envelope })
|
||||
return { ok: true }
|
||||
}
|
||||
definitionsDb.getById = async (id) => (id === DEFINITION.id ? { ...DEFINITION } : null)
|
||||
participantsDb.countForRun = async () => 12
|
||||
logDb.write = async (line) => {
|
||||
lines.push(line)
|
||||
}
|
||||
})
|
||||
|
||||
after(() => {
|
||||
engagementEmit.emit = originals.emit
|
||||
definitionsDb.getById = originals.getById
|
||||
participantsDb.countForRun = originals.count
|
||||
logDb.write = originals.write
|
||||
})
|
||||
|
||||
const only = () => {
|
||||
assert.equal(emitted.length, 1, `expected one emit, got ${emitted.length}`)
|
||||
return emitted[0]
|
||||
}
|
||||
|
||||
// ── The shared envelope ────────────────────────────────────────────────────
|
||||
|
||||
test('core emits as core, keyed on the run, with the definition\'s own facts', async () => {
|
||||
await announce.runStarted(RUN)
|
||||
const { owner, triggerId, envelope } = only()
|
||||
|
||||
assert.equal(owner, 'core')
|
||||
assert.equal(triggerId, 'event.run.started')
|
||||
// Keyed on the RUN. Two occurrences of a weekly event are two subjects, so
|
||||
// last week's mail does not throttle this week's.
|
||||
assert.equal(envelope.subject, '3692')
|
||||
assert.equal(envelope.scopeKey, 'event:3692')
|
||||
assert.equal(envelope.data.runId, '3692')
|
||||
assert.equal(envelope.data.title, 'The Yew Invasion')
|
||||
assert.equal(envelope.data.summary, DEFINITION.summary)
|
||||
assert.equal(envelope.data.seriesName, 'The Yew Campaign')
|
||||
})
|
||||
|
||||
test('a real run carries NO ceiling, so the declaration\'s own is what bounds it', async () => {
|
||||
await announce.runStarted(RUN)
|
||||
assert.equal(only().envelope.ceiling, undefined)
|
||||
})
|
||||
|
||||
test('a REHEARSAL fires the same trigger and ceilings it at staff', async () => {
|
||||
// §I: "run for real with announcements ceilinged to `staff`". Emitting
|
||||
// nothing would be a rehearsal of everything except the announcements.
|
||||
await announce.runStarted({ ...RUN, rehearsal: 1 })
|
||||
const { triggerId, envelope } = only()
|
||||
assert.equal(triggerId, 'event.run.started')
|
||||
assert.equal(envelope.ceiling, 'staff')
|
||||
})
|
||||
|
||||
test('the run log says what was announced and, on a rehearsal, why it was bounded', async () => {
|
||||
await announce.runStarted({ ...RUN, rehearsal: 1 })
|
||||
const line = lines.find((l) => l.kind === 'announcement.emitted')
|
||||
assert.equal(line.detail.trigger, 'event.run.started')
|
||||
assert.equal(line.detail.ceiling, 'staff')
|
||||
assert.equal(line.detail.because, 'rehearsal')
|
||||
// How many people were told is the engagement engine's decision and its own
|
||||
// log line. A run log that reported a number would be claiming a decision it
|
||||
// does not make.
|
||||
assert.equal(line.detail.recipients, undefined)
|
||||
})
|
||||
|
||||
test('a definition that has gone away announces nothing and does not throw', async () => {
|
||||
await announce.runStarted({ ...RUN, definition_id: 999 })
|
||||
assert.equal(emitted.length, 0)
|
||||
assert.equal(lines.length, 0)
|
||||
})
|
||||
|
||||
test('a read that throws is swallowed — a run must not fail to start over an announcement', async () => {
|
||||
definitionsDb.getById = async () => {
|
||||
throw new Error('pool timeout')
|
||||
}
|
||||
await announce.runStarted(RUN)
|
||||
assert.equal(emitted.length, 0)
|
||||
})
|
||||
|
||||
// ── Per-moment payloads ────────────────────────────────────────────────────
|
||||
|
||||
test('phase.changed counts phases from one, for a human reading a sentence', async () => {
|
||||
await announce.phaseChanged(RUN, { phase: 'assault', label: 'The assault', index: 1, count: 4 })
|
||||
const { data } = only().envelope
|
||||
assert.equal(data.phase, 'assault')
|
||||
assert.equal(data.phaseLabel, 'The assault')
|
||||
assert.equal(data.phaseIndex, 2)
|
||||
assert.equal(data.phaseCount, 4)
|
||||
})
|
||||
|
||||
test('phase.changed falls back to the key when a phase has no label', async () => {
|
||||
await announce.phaseChanged(RUN, { phase: 'assault', label: null, index: 0, count: 2 })
|
||||
assert.equal(only().envelope.data.phaseLabel, 'assault')
|
||||
})
|
||||
|
||||
test('run.completed counts the participants and the minutes it took', async () => {
|
||||
await announce.runCompleted(RUN, new Date('2026-09-13T01:35:14.000Z'))
|
||||
const { data } = only().envelope
|
||||
assert.equal(data.participantCount, 12)
|
||||
assert.equal(data.durationMinutes, 95)
|
||||
})
|
||||
|
||||
test('a participant count that cannot be read is zero, not missing', async () => {
|
||||
// The variable is declared `required`, so it has to be a number — and zero is
|
||||
// also the honest answer for the far more common case of a run nothing
|
||||
// collected for.
|
||||
participantsDb.countForRun = async () => {
|
||||
throw new Error('table is gone')
|
||||
}
|
||||
await announce.runCompleted(RUN, new Date('2026-09-13T00:30:14.000Z'))
|
||||
assert.equal(only().envelope.data.participantCount, 0)
|
||||
})
|
||||
|
||||
test('a run that never started reports no duration rather than a negative one', async () => {
|
||||
await announce.runCompleted({ ...RUN, started_at: null }, new Date('2026-09-13T01:00:00.000Z'))
|
||||
assert.equal(only().envelope.data.durationMinutes, 0)
|
||||
})
|
||||
|
||||
test('run.cancelled carries the operator\'s reason, and omits it when none was given', async () => {
|
||||
await announce.runCancelled(RUN, 'The shard is down for an emergency patch.')
|
||||
assert.equal(only().envelope.data.reason, 'The shard is down for an emergency patch.')
|
||||
emitted = []
|
||||
await announce.runCancelled(RUN, null)
|
||||
assert.equal(only().envelope.data.reason, undefined)
|
||||
})
|
||||
|
||||
test('run.failed links the run console — the one destination that exists today', async () => {
|
||||
await announce.runFailed(RUN, 'sidecar responded 503')
|
||||
const { data } = only().envelope
|
||||
assert.equal(data.error, 'sidecar responded 503')
|
||||
assert.equal(data.phase, 'assault')
|
||||
assert.equal(data.runUrl, '/admin/events/runs/3692')
|
||||
})
|
||||
|
||||
test('run.failed falls back to the run\'s own last error', async () => {
|
||||
await announce.runFailed({ ...RUN, last_error: 'the pinned version has no phases' }, null)
|
||||
assert.equal(only().envelope.data.error, 'the pinned version has no phases')
|
||||
})
|
||||
|
||||
// ── startsAtLabel: the presentational fragment ─────────────────────────────
|
||||
|
||||
test('the start time is written out in the SHARD\'s zone, not the server\'s', () => {
|
||||
// Midnight UTC on the 13th is 8pm on the 12th in New York, and the whole point
|
||||
// of the label is that a reader sees the shard's evening.
|
||||
const label = announce.startsAtLabel(new Date('2026-09-13T00:00:00Z'), 'America/New_York')
|
||||
assert.match(label, /^Saturday 12 September at 8:00 pm \(America\/New_York\)$/)
|
||||
})
|
||||
|
||||
test('midnight reads as 12:00 am and never as 00:00', () => {
|
||||
// `hour12` is set explicitly. Left to the en-GB locale this would render
|
||||
// "00:00" while the schedule editor beside it writes "12:00 AM" — one event,
|
||||
// two spellings of the same instant.
|
||||
assert.match(announce.startsAtLabel(new Date('2026-09-13T04:00:00Z'), 'America/New_York'), /12:00 am/)
|
||||
})
|
||||
|
||||
test('a bad zone or a bad instant answers nothing rather than throwing', () => {
|
||||
// The variable is optional and its template block is a single token, so an
|
||||
// absent label renders as nothing at all rather than as a broken line.
|
||||
assert.equal(announce.startsAtLabel(new Date('2026-09-13T00:00:00Z'), 'Middle/Earth'), undefined)
|
||||
assert.equal(announce.startsAtLabel('not a date', 'UTC'), undefined)
|
||||
})
|
||||
|
||||
test('the label rides on the two triggers that have a start time', async () => {
|
||||
await announce.runScheduled(RUN)
|
||||
assert.match(only().envelope.data.startsAtLabel, /8:00 pm \(America\/New_York\)/)
|
||||
emitted = []
|
||||
await announce.runEnding(RUN)
|
||||
assert.equal(only().envelope.data.startsAtLabel, undefined)
|
||||
})
|
||||
|
||||
// ── The declarations themselves ────────────────────────────────────────────
|
||||
|
||||
const eventTriggers = () => TRIGGERS.filter((t) => t.id.startsWith('event.'))
|
||||
|
||||
test('seven event triggers, and every one is keyed on the run', () => {
|
||||
const ids = eventTriggers().map((t) => t.id)
|
||||
assert.deepEqual(ids, [
|
||||
'event.run.scheduled',
|
||||
'event.run.started',
|
||||
'event.phase.changed',
|
||||
'event.run.ending',
|
||||
'event.run.completed',
|
||||
'event.run.cancelled',
|
||||
'event.run.failed',
|
||||
])
|
||||
for (const t of eventTriggers()) {
|
||||
assert.equal(t.subjectKey, 'runId', `${t.id} must key its cooldown on the run`)
|
||||
assert.ok(
|
||||
t.variables.some((v) => v.name === 'runId' && v.required),
|
||||
`${t.id} declares subjectKey runId, so runId must be a required variable`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('six are ceilinged authenticated; run.failed is admin on both halves', () => {
|
||||
for (const t of eventTriggers()) {
|
||||
if (t.id === 'event.run.failed') {
|
||||
// A failure names the deployment's own broken machinery. There is no
|
||||
// widening of this that is not a disclosure, so the CEILING says so.
|
||||
assert.equal(t.ceiling, 'admin')
|
||||
assert.equal(t.audience, 'admin')
|
||||
} else {
|
||||
assert.equal(t.ceiling, 'authenticated', `${t.id}`)
|
||||
assert.equal(t.audience, 'subscribers', `${t.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('no public event trigger declares a url — there is no page for one to point at yet', () => {
|
||||
// `news.post` shipped an example naming `/news/<slug>`, a path that does not
|
||||
// exist, and the template editor previewed a link that was dead in every mail
|
||||
// it sent. Phase 14 adds the variable alongside the page.
|
||||
for (const t of eventTriggers()) {
|
||||
const urls = t.variables.filter((v) => v.type === 'url')
|
||||
if (t.id === 'event.run.failed') {
|
||||
assert.deepEqual(urls.map((v) => v.name), ['runUrl'])
|
||||
assert.match(urls[0].example, /^\/admin\/events\/runs\//)
|
||||
} else {
|
||||
assert.deepEqual(urls, [], `${t.id} must declare no url until Phase 14`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every declared variable carries an example, which is what the editor previews with', () => {
|
||||
for (const t of eventTriggers()) {
|
||||
for (const v of t.variables) {
|
||||
assert.notEqual(v.example, undefined, `${t.id}.${v.name} needs an example`)
|
||||
assert.ok(v.description, `${t.id}.${v.name} needs a description`)
|
||||
}
|
||||
}
|
||||
})
|
||||
311
server/test/eventIntegrations.test.js
Normal file
311
server/test/eventIntegrations.test.js
Normal file
@@ -0,0 +1,311 @@
|
||||
// ── The integrations (EVENTS_PLAN.md Phase 10) ─────────────────────────────
|
||||
//
|
||||
// §J's three reuse claims, each turned into something that fails when the reuse
|
||||
// stops being real:
|
||||
//
|
||||
// • **`core.announce.post` reuses the announce legs** rather than growing a
|
||||
// second delivery pipeline — so the assertions are about what it puts in
|
||||
// `announce_jobs`, not about what reaches Discord.
|
||||
// • **an event's job must not stand on the news pipeline's toes.** A post may
|
||||
// now have two jobs, and everything that reads "the post's job" — the admin
|
||||
// panel, its retry button, `announced_at` — must still mean the news one.
|
||||
// This is the half that would be silent: nothing errors, the panel just
|
||||
// starts showing a different row.
|
||||
// • **`core.results.publish` is idempotent**, because it is an ordinary step
|
||||
// an author may place twice and the runner may retry.
|
||||
//
|
||||
// And the two seeded rules, checked against the declarations they name. A seeded
|
||||
// rule pointing at a template that does not exist, or at an audience its own
|
||||
// trigger's ceiling forbids, is a rule that fails on the first firing after an
|
||||
// operator switches it on — which is the worst possible moment to find out.
|
||||
|
||||
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 registries = require('../src/modules/registries')
|
||||
const ceilings = require('../src/modules/ceilings')
|
||||
const coreRules = require('../src/engagement/coreRules')
|
||||
const templateSeeds = require('../src/engagement/templateSeeds')
|
||||
const { TRIGGERS } = require('../src/config/coreTriggers')
|
||||
const postsDb = require('../src/model/posts/posts.db')
|
||||
const postsModel = require('../src/model/posts/posts.model')
|
||||
const announceDb = require('../src/model/announceJobs/announceJobs.db')
|
||||
const announceModel = require('../src/model/announceJobs/announceJobs.model')
|
||||
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||
const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// ── core.announce.post ─────────────────────────────────────────────────────
|
||||
|
||||
const POSTS = {
|
||||
1: { id: 1, category: 'news', title: 'The Yew Invasion', published: 1, announce_job_id: 9 },
|
||||
2: { id: 2, category: 'news', title: 'A draft', published: 0, announce_job_id: null },
|
||||
3: { id: 3, category: 'newsletter', title: 'Never announced', published: 1, announce_job_id: null },
|
||||
}
|
||||
|
||||
let enqueued
|
||||
let linked
|
||||
const originals = {
|
||||
getById: postsModel.getById,
|
||||
link: postsModel.linkAnnounceJob,
|
||||
create: announceDb.create,
|
||||
legIds: registries.announceLegIds,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registries._reset()
|
||||
registries.registerCore()
|
||||
enqueued = []
|
||||
linked = []
|
||||
postsModel.getById = async (id) => POSTS[id] || null
|
||||
postsModel.linkAnnounceJob = async (id, jobId) => {
|
||||
linked.push({ id, jobId })
|
||||
}
|
||||
announceDb.create = async (postId, legs, opts = {}) => {
|
||||
enqueued.push({ postId, legs, runId: opts.runId ?? null })
|
||||
return 100 + enqueued.length
|
||||
}
|
||||
})
|
||||
|
||||
after(() => {
|
||||
postsModel.getById = originals.getById
|
||||
postsModel.linkAnnounceJob = originals.link
|
||||
announceDb.create = originals.create
|
||||
registries.announceLegIds = originals.legIds
|
||||
})
|
||||
|
||||
const announcePost = () => registries.eventAction('core.announce.post')
|
||||
|
||||
test('a published post is queued on every registered leg, tagged with the run', async () => {
|
||||
const answer = await announcePost().perform({ runId: 3692, params: { postId: 1 }, verify: false })
|
||||
assert.deepEqual(answer, { ok: true })
|
||||
assert.equal(enqueued.length, 1)
|
||||
assert.equal(enqueued[0].postId, 1)
|
||||
assert.equal(enqueued[0].runId, 3692)
|
||||
// The legs come from the registry, so a module's leg is included without this
|
||||
// action naming one — which is the whole of "reuse the legs".
|
||||
assert.deepEqual(enqueued[0].legs, registries.announceLegIds())
|
||||
})
|
||||
|
||||
test('an unpublished post is refused, and refused terminally', async () => {
|
||||
// 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. Publishing is the CMS's decision and this action is not it.
|
||||
const answer = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: false })
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, false)
|
||||
assert.match(answer.error, /not published/)
|
||||
assert.equal(enqueued.length, 0)
|
||||
})
|
||||
|
||||
test('a post id that names nothing is refused terminally — it will not appear in sixty seconds', async () => {
|
||||
for (const bad of [999, 0, -1, 'twelve', null]) {
|
||||
const answer = await announcePost().perform({ runId: 1, params: { postId: bad }, verify: false })
|
||||
assert.equal(answer.ok, false, `${bad}`)
|
||||
assert.equal(answer.retry, false, `${bad}`)
|
||||
}
|
||||
assert.equal(enqueued.length, 0)
|
||||
})
|
||||
|
||||
test('a dry run checks the post and queues nothing', async () => {
|
||||
const answer = await announcePost().perform({ runId: 1, params: { postId: 1 }, verify: true })
|
||||
assert.deepEqual(answer, { ok: true })
|
||||
assert.equal(enqueued.length, 0)
|
||||
|
||||
// …and still refuses what the real run would refuse, which is the point of a
|
||||
// dry run: the cost of finding out is a page, not a half-changed world.
|
||||
const refused = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: true })
|
||||
assert.equal(refused.ok, false)
|
||||
})
|
||||
|
||||
test('the post\'s back-pointer is left alone when it already has one', async () => {
|
||||
// `announce_job_id` is what the post admin panel reads and what
|
||||
// `shouldEnqueue` guards on. Moving it to an event's job would make a
|
||||
// re-published post announce itself again.
|
||||
await announceModel.enqueueForRun(1, 3692)
|
||||
assert.deepEqual(linked, [])
|
||||
})
|
||||
|
||||
test('a post that has never been announced gains the pointer, because this IS its announcement', async () => {
|
||||
await announceModel.enqueueForRun(3, 3692)
|
||||
assert.equal(linked.length, 1)
|
||||
assert.equal(linked[0].id, 3)
|
||||
})
|
||||
|
||||
// ── the news pipeline is untouched ─────────────────────────────────────────
|
||||
|
||||
test('"the post\'s job" still means the news one, however many an event has added', async () => {
|
||||
// The half that would be silent: nothing errors, the admin panel just starts
|
||||
// rendering an event's job and its retry button retries that instead.
|
||||
//
|
||||
// Asserted against the query's own text rather than by stubbing the pool.
|
||||
// `announceJobs.db.js` destructures `query` at require time, so a stub on the
|
||||
// db module here would replace something nothing reads — and the test would
|
||||
// then pass whatever the SQL said, which is the one thing it exists to check.
|
||||
const source = require('node:fs').readFileSync(
|
||||
require.resolve('../src/model/announceJobs/announceJobs.db'),
|
||||
'utf8',
|
||||
)
|
||||
const fn = source.slice(source.indexOf('async function findByPostId'))
|
||||
assert.match(fn.slice(0, fn.indexOf('\n}')), /run_id IS NULL/)
|
||||
})
|
||||
|
||||
test('a run\'s job does not restamp the post\'s announced_at', async () => {
|
||||
// `announced_at` means "when this post was announced". An event linking a
|
||||
// three-week-old article would otherwise rewrite that to today, and the admin
|
||||
// panel would report a publication date the post does not have.
|
||||
const stamped = []
|
||||
const saved = { findById: announceDb.findById, setStatus: announceDb.setStatus, markAnnounced: postsModel.markAnnounced }
|
||||
announceDb.findById = async (id) => ({
|
||||
id,
|
||||
post_id: 1,
|
||||
run_id: id === 1 ? null : 3692,
|
||||
status: 'pending',
|
||||
legs: [{ leg: 'discord', status: 'done' }],
|
||||
})
|
||||
announceDb.setStatus = async () => {}
|
||||
postsModel.markAnnounced = async (postId) => {
|
||||
stamped.push(postId)
|
||||
}
|
||||
try {
|
||||
await announceModel.refreshStatus(2) // a run's job
|
||||
assert.deepEqual(stamped, [])
|
||||
await announceModel.refreshStatus(1) // the news pipeline's own
|
||||
assert.deepEqual(stamped, [1])
|
||||
} finally {
|
||||
Object.assign(announceDb, { findById: saved.findById, setStatus: saved.setStatus })
|
||||
postsModel.markAnnounced = saved.markAnnounced
|
||||
}
|
||||
})
|
||||
|
||||
// ── core.results.publish ───────────────────────────────────────────────────
|
||||
|
||||
test('publishing ranks the run\'s participants and stamps it, and does neither on a dry run', async () => {
|
||||
const calls = []
|
||||
const saved = { rank: participantsDb.rankRun, mark: runsDb.markResultsPublished }
|
||||
participantsDb.rankRun = async (id) => {
|
||||
calls.push(['rank', id])
|
||||
return 3
|
||||
}
|
||||
runsDb.markResultsPublished = async (id) => {
|
||||
calls.push(['stamp', id])
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const action = registries.eventAction('core.results.publish')
|
||||
assert.deepEqual(await action.perform({ runId: 3692, verify: true }), { ok: true })
|
||||
assert.deepEqual(calls, [])
|
||||
|
||||
assert.deepEqual(await action.perform({ runId: 3692, verify: false }), { ok: true })
|
||||
assert.deepEqual(calls, [['rank', 3692], ['stamp', 3692]])
|
||||
|
||||
// Idempotent by construction: the ranking is a total order over
|
||||
// `(score, joined_at, id)`, so a second publication writes the same numbers.
|
||||
// That is what makes it safe as an ordinary retried step.
|
||||
await action.perform({ runId: 3692, verify: false })
|
||||
assert.equal(calls.length, 4)
|
||||
} finally {
|
||||
participantsDb.rankRun = saved.rank
|
||||
runsDb.markResultsPublished = saved.mark
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing takes no params — a run may not publish somebody else\'s results', async () => {
|
||||
assert.deepEqual(registries.eventAction('core.results.publish').params, [])
|
||||
})
|
||||
|
||||
test('publishing is `inspect`, so it is default-ON and an author can place it unaided', () => {
|
||||
// Nothing in the game world changes and nobody is messaged: a table core
|
||||
// already holds becomes readable.
|
||||
const action = registries.eventAction('core.results.publish')
|
||||
assert.equal(action.risk, 'inspect')
|
||||
assert.equal(action.reversible, 'none')
|
||||
})
|
||||
|
||||
// ── the option source ──────────────────────────────────────────────────────
|
||||
|
||||
test('the posts option source answers value/label/group from published posts only', async () => {
|
||||
const saved = postsDb.listPublishedForOptions
|
||||
postsDb.listPublishedForOptions = async () => [
|
||||
{ id: 1, category: 'news', title: 'The Yew Invasion' },
|
||||
{ id: 3, category: 'newsletter', title: 'Never announced' },
|
||||
]
|
||||
try {
|
||||
// Through the resolver the catalog route uses, so the normalisation it
|
||||
// applies — every value stringified for the `<select>` — 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)
|
||||
})
|
||||
193
server/test/eventParticipants.test.js
Normal file
193
server/test/eventParticipants.test.js
Normal file
@@ -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)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')])
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user