`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
// ── Event runs — creating an occurrence ────────────────────────────────────
|
|
//
|
|
// EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's
|
|
// steps. **It does not start anything**: there is no runner until Phase 2, so a
|
|
// row created here sits at `scheduled` indefinitely. That is the correct
|
|
// behaviour for this phase and it has to be VISIBLE as such rather than looking
|
|
// broken, which is why `create()` answers with the row and the admin surface
|
|
// renders the status verbatim.
|
|
//
|
|
// Two properties this file owns, both of which are the reason it exists before
|
|
// the runner rather than with it:
|
|
//
|
|
// - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two
|
|
// attempts at one occurrence produce one row and an honest answer, not a
|
|
// duplicate-key error a caller has to interpret. The unique index — not a
|
|
// claim — is what makes "one run per occurrence per scope" true (§E).
|
|
// - **The idempotency key is minted with the step row and never varies by
|
|
// attempt.** It is a function of identity, so it can only be stable if it is
|
|
// stamped where the identity is created.
|
|
|
|
const db = require('./eventRuns.db')
|
|
const stepsDb = require('./eventRunSteps.db')
|
|
const logDb = require('./eventRunLog.db')
|
|
const gatesDb = require('./eventPhaseGates.db')
|
|
const gates = require('../../events/gates')
|
|
const definitionsDb = require('./eventDefinitions.db')
|
|
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
|
|
|
|
/**
|
|
* Render a definition's `concurrency_key` template against a run's params.
|
|
*
|
|
* `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A
|
|
* placeholder with no matching param is left standing rather than replaced with
|
|
* an empty string: `invasion:` would collide with every other unrendered key on
|
|
* the deployment, which is the opposite of what a concurrency key is for, and a
|
|
* literal `invasion:{region}` in the column is a visible mistake.
|
|
*/
|
|
function renderConcurrencyKey(template, params) {
|
|
if (!template) return null
|
|
return String(template)
|
|
.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => {
|
|
const value = params && params[name]
|
|
return value === undefined || value === null || value === '' ? whole : String(value)
|
|
})
|
|
.slice(0, MAX_SCOPE)
|
|
}
|
|
|
|
/**
|
|
* Create one occurrence of a definition and materialise its first phase.
|
|
*
|
|
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
|
|
* the present, not a separate concept, which is what keeps the runner's one
|
|
* materialise/advance path honest now that Phase 4 has put recurrence on top.
|
|
*
|
|
* **Phase 4's expansion calls this, rather than a second insert path beside it.**
|
|
* That is deliberate: every check here — the definition is still `ready`, the
|
|
* version still has phases, the concurrency key renders, the first phase's steps
|
|
* are materialised with their idempotency keys — is one a scheduled occurrence
|
|
* needs at least as much as a hand-started one, because there is nobody watching
|
|
* when it happens. The `INSERT IGNORE` answering `created: false` is what makes
|
|
* it safe to call on every tick for every occurrence inside the horizon.
|
|
*/
|
|
async function create(
|
|
definitionId,
|
|
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
|
|
userId,
|
|
) {
|
|
const definition = await definitionsDb.getById(definitionId)
|
|
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
|
|
if (definition.state !== 'ready') {
|
|
return {
|
|
ok: false,
|
|
status: 409,
|
|
errors: [`a ${definition.state} definition has no published version to run`],
|
|
}
|
|
}
|
|
if (!definition.current_version_id) {
|
|
return { ok: false, status: 409, errors: ['this definition has no published version'] }
|
|
}
|
|
|
|
const version = await versionsDb.getById(definition.current_version_id)
|
|
if (!version?.spec?.phases?.length) {
|
|
return { ok: false, status: 409, errors: ['the published version has no phases'] }
|
|
}
|
|
|
|
// §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03):
|
|
// *"a scheduled definition that has never been verified is the case worth
|
|
// refusing to start"*. An admin pressing start is watching, and that human IS
|
|
// the review the gate exists to require — so the gate falls on the path where
|
|
// nobody is. A version is immutable, so a dry run that passed against it stays
|
|
// true, which is what makes the pass a property of the version rather than
|
|
// something re-earned every occurrence.
|
|
if (source === 'schedule' && !version.verified_at) {
|
|
return {
|
|
ok: false,
|
|
status: 409,
|
|
code: 'unverified',
|
|
errors: ['this version has not been verified, so it will not start unattended'],
|
|
}
|
|
}
|
|
|
|
const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
|
|
const when = scheduledFor ? new Date(scheduledFor) : new Date()
|
|
if (Number.isNaN(when.getTime())) {
|
|
return { ok: false, status: 400, errors: ['scheduledFor is not a date'] }
|
|
}
|
|
|
|
const runId = await db.materialise({
|
|
definition_id: definitionId,
|
|
version_id: version.id,
|
|
scope: scopeValue,
|
|
// Stored as UTC. The definition's zone is what an occurrence is COMPUTED in
|
|
// (Phase 4); what is stored is the instant.
|
|
scheduled_for: when,
|
|
timezone: definition.timezone,
|
|
concurrency_key: renderConcurrencyKey(definition.concurrency_key, params),
|
|
params,
|
|
rehearsal,
|
|
started_by: userId,
|
|
})
|
|
|
|
if (runId === null) {
|
|
// The occurrence already existed. Not an error — it is what the unique index
|
|
// is for — so the existing row is the answer.
|
|
const existing = await db.findOccurrence(definitionId, scopeValue, when)
|
|
return { ok: true, created: false, run: existing }
|
|
}
|
|
|
|
await logDb.write({
|
|
runId,
|
|
kind: 'run.created',
|
|
detail: {
|
|
definitionId,
|
|
versionId: version.id,
|
|
version: version.version,
|
|
scope: scopeValue,
|
|
rehearsal: Boolean(rehearsal),
|
|
// 'manual' is an admin pressing start; 'schedule' is the runner expanding
|
|
// a recurrence (Phase 4). Both produce the same row, and the log is the
|
|
// only place the difference is recorded — `started_by` is NULL for both a
|
|
// scheduled occurrence and one started by a since-deleted account.
|
|
source,
|
|
by: userId,
|
|
},
|
|
})
|
|
|
|
// The run's budget, seeded from EVERY phase's steps rather than from the first
|
|
// one's (Phase 6). The version is pinned and immutable, so all of its steps are
|
|
// knowable now — and a budget that grew as phases were entered would let a
|
|
// phase-1 step spend a cap that a phase-3 step was going to need, which is the
|
|
// opposite of a per-run bound. The caps are copied here, so an admin moving a
|
|
// switch tomorrow does not change what a run already in flight is allowed.
|
|
const allSteps = version.spec.phases.flatMap((p) =>
|
|
(p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })),
|
|
)
|
|
const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId))
|
|
const budget = authorize.effectiveCaps(allSteps, settingsByAction)
|
|
if (Object.keys(budget).length) {
|
|
await budgetDb.seed(runId, budget)
|
|
await logDb.write({
|
|
runId,
|
|
kind: 'run.budget',
|
|
detail: {
|
|
dimensions: Object.entries(budget).map(([dimension, d]) => ({
|
|
dimension,
|
|
cap: d.cap,
|
|
from: d.from,
|
|
})),
|
|
},
|
|
})
|
|
}
|
|
|
|
// The first phase's steps, materialised at creation rather than at start.
|
|
// Phase 2 materialises each LATER phase as the run enters it; doing the first
|
|
// one here is what makes a Phase 1 run row inspectable — an operator can see
|
|
// the steps that would run, with their params and their idempotency keys,
|
|
// before there is anything to run them.
|
|
const first = version.spec.phases[0]
|
|
await stepsDb.materialisePhase(runId, first.key, first.steps || [])
|
|
await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } })
|
|
|
|
return { ok: true, created: true, run: await db.getById(runId) }
|
|
}
|
|
|
|
/**
|
|
* A run, its steps, its status counts and its phase gates — the run console.
|
|
*
|
|
* The gates arrive already DESCRIBED rather than as rows (Phase 5): the panel's
|
|
* whole value is that it reads the way the condition builder reads, and those
|
|
* words come from `engagement/conditions.js`'s own operator labels. Rendering
|
|
* them in the browser would be a second implementation of a grammar the server
|
|
* owns, and the first clause the two spelled differently would meet its operator
|
|
* at two in the morning.
|
|
*
|
|
* Every gate the run has opened is returned, not only the current phase's. A
|
|
* completed phase's gate answers "how long did phase 2 actually wait, and what
|
|
* released it" — which is the same question as the live one, asked afterwards.
|
|
*/
|
|
async function detail(runId) {
|
|
const run = await db.getById(runId)
|
|
if (!run) return null
|
|
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 {
|
|
run,
|
|
steps,
|
|
counts,
|
|
gates: gateRows.map((g) => gates.describe(g, now)),
|
|
// The meter, as rows rather than as a sentence: a cap is two numbers and a
|
|
// name, and unlike a gate it needs no grammar rendered to be read. `cap:
|
|
// null` is uncapped and the client says so — a dimension the run counts but
|
|
// nothing bounds.
|
|
budget: budget.map((b) => ({
|
|
dimension: b.dimension,
|
|
consumed: b.consumed,
|
|
cap: b.cap,
|
|
from: b.effective_from,
|
|
})),
|
|
// What this run changed in the world, and what became of it (Phase 8). The
|
|
// WHOLE ledger, reverted rows included, because "what did last night's
|
|
// invasion actually spawn, and did all of it come back" is the question this
|
|
// panel exists for and a list of only the failures cannot answer the second
|
|
// half of it.
|
|
//
|
|
// **The `@step` placeholders are filtered out.** They are core's own
|
|
// bookkeeping — a row that says "a dispatch is in flight and may have made
|
|
// something" — and the console's list is of things in the world. One left in
|
|
// would read as a resource nobody can name, which is exactly the confusion it
|
|
// exists to prevent internally.
|
|
resources: resources
|
|
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
|
|
.map((r) => ({
|
|
id: r.id,
|
|
stepId: r.step_id,
|
|
module: r.owner_module,
|
|
kind: r.kind,
|
|
ref: r.ref,
|
|
payload: r.payload,
|
|
leaseUntil: r.lease_until,
|
|
status: r.status,
|
|
revertAttempts: r.revert_attempts,
|
|
lastError: r.last_error,
|
|
memberKey: r.member_key,
|
|
createdAt: r.created_at,
|
|
})),
|
|
// How many rows are still unresolved, counted over the WHOLE ledger rather
|
|
// 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,
|
|
})),
|
|
}
|
|
}
|
|
|
|
module.exports = { create, detail, renderConcurrencyKey }
|