Files
website/server/src/config/coreEventActions.js
wtclaude 37f4623068
Some checks failed
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Failing after 5m46s
PR Checks / bot-tests (pull_request) Successful in 8m28s
feat(events): targeted leases, value sets and searchable sources (Phase 12b)
The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.

Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it is
one capability over thousands of spawners, and a reservation on the id alone
would let one run turning up one spawner refuse every other run every other
spawner. So a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving it coming from
the same unique index it always did.

Extending core rather than giving the module a lease verb of its own is what §F
decided in Phase 8 ("the verb is core's"): a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory
everywhere and wrong in the first one that forgot. Half that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.

Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.

`values` closes a `string` lease's set. `min`/`max` bound the numeric types and
nothing bounded `string`, so the only check on a string lease's value was the
game side's -- a refusal arriving unattended, mid-run, from a step nobody is
watching. Refused on any other type: a set beside `min`/`max` would be a second
bound with no rule about which wins.

Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the failure 12a named for decoration, arriving
for real. `resolve({ q })` is additive: every source is passed a term, none is
required to read one, and a `searchable` flag says which do, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.

`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.

The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.

2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.

Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:06:37 -05:00

756 lines
34 KiB
JavaScript

// ── Core's own event actions ───────────────────────────────────────────────
//
// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js
// and registered through the same staging area a module will use in Phase 7 —
// which is the entire reason these three exist this early. A registry whose first
// real registrant is a module is a registry that has already drifted, and §F's
// claim that core is "an event engine that can announce, wait, cue a human and
// publish results" with NO module installed is only true if core declares the
// verbs that do it.
//
// **Three actions, and between them they cover the three things an event can do
// that name no game noun at all**: tell people something, let time pass, and ask
// a human to go and do something. A deployment with no game module installed has
// a working event system made of exactly these.
//
// **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease`
// names no game noun either — it borrows a value some module declared — but
// unlike the other three it genuinely changes the world, so it is `risk: 'change'`
// and therefore default-off, admin-only and cap-checked like any module verb.
// It is CORE's rather than each module's because §F puts the duration bound and
// the two-events-one-target conflict check on core's side of the seam: a lease
// 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
// without finishing at all. The runner learns nothing about any of them by id —
// each says what it needs in the envelope, through the same two members Phase 7
// hands to a module.
//
// **This file must not touch the database.** It is required from `registerCore()`,
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
// looked up inside `perform()`, per call, which is also what makes a leg
// registered by a module that booted later reachable at all. `core.lease` is the
// one action here that reaches a table, and it requires the model INSIDE
// `perform()` for the same reason — a top-level require would make this file
// build a pool during route-manifest generation.
const registries = require('../modules/registries')
// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref
// from the lease id and the target, so this is the one place a caller can push a
// ref past the column — and the ledger's own rule applies: refuse, never
// truncate, because a truncated ref is a restore pointed at another object.
const MAX_LEASE_REF = 190
/**
* Turn the `value` param's text into whatever the named lease says it holds.
*
* The range check is here too, and it is REQUIRED on the numeric types for the
* reason §F gives: unlike a cap, a bad lease value is in force the moment it is
* applied, so "0.5 to 5" is not advice.
*/
function coerceLeaseValue(lease, raw) {
const text = String(raw === undefined || raw === null ? '' : raw).trim()
if (lease.type === 'string') {
// A string lease with a declared value set is bounded here, at authoring
// time, exactly as a numeric one is by its range (Phase 12b). Without it the
// only check on the value is the game side's, and that refusal arrives
// unattended, mid-run, from a step nobody is watching.
if (lease.values && !lease.values.includes(text)) {
return {
ok: false,
error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`,
}
}
return { ok: true, value: text }
}
if (lease.type === 'bool') {
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` }
}
const n = Number(text)
if (text === '' || !Number.isFinite(n)) {
return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` }
}
if (lease.type === 'int' && !Number.isInteger(n)) {
return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` }
}
if (n < lease.min || n > lease.max) {
return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` }
}
return { ok: true, value: n }
}
const ACTIONS = [
{
id: 'core.announce',
label: 'Announce',
description:
'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.',
// Nothing in the world changes and nothing is created: a message goes out.
// That is what makes the default `on_failure` for this step `retry -> skip`
// (§L) rather than `pause`, and it is the honest class even though the
// message itself cannot be unsent.
risk: 'notify',
// A sent announcement is gone. `none` rather than `ledger` is not an
// omission — there is no undo to write, and declaring `ledger` would put a
// row in the cleanup ledger that teardown could never resolve.
reversible: 'none',
version: 1,
params: [
{
// A leg id, checked against the announce-leg registry at dispatch rather
// than here: legs are registered by modules, and this file is evaluated
// before any module has registered anything.
//
// **`source` is what moves that check earlier** (Phase 7). The dispatch
// check stays — a module can boot between authoring and the run — but
// until now a typo here was caught mid-run and nowhere else, which is the
// defect Phase 6's walk hit: an announce leg "site" no module registers,
// found by a dry run rather than by the form that accepted it.
name: 'leg',
type: 'string',
required: true,
example: 'discord',
source: 'core.options.legs',
description: 'The announce leg to publish on. Registered legs only.',
},
{
name: 'title',
type: 'string',
required: false,
example: 'The gates of Britain open at dusk',
description: 'Optional heading, for legs that render one.',
},
{
name: 'body',
type: 'string',
required: true,
example: 'A caravan has been sighted on the road east of Cove.',
description: 'The announcement itself. Plain text.',
},
],
/**
* Publish through the announce leg the step names.
*
* **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
* `discord` is core's and `towncrier` is module-uo's, both already registered,
* both already carrying a `classify()` that knows what their transport's
* failures mean. An event announcement that went out by some other path would
* be a second delivery mechanism with its own bugs.
*
* A leg's `dispatch()` takes a POST — that is the shape the news path gave it
* — so an event announcement is presented as one. `excerpt` is the body
* because it is the field every leg renders as prose, and `image_url` is null
* because an event announcement has no article behind it to illustrate.
* Widening the leg contract to carry a second payload shape is a
* MODULE_API change, and Phase 7 is where those are made.
*
* The leg id is checked HERE rather than at authoring time, and that is not
* laxness: legs are registered by modules, and a spec is validated in a
* process that may have booted before the module that owns the leg.
*/
async perform({ params, verify }) {
const registered = registries.announceLeg(params.leg)
if (!registered) {
// Terminal, not transient. A leg nobody registers will not appear
// between two attempts sixty seconds apart, and the honest cause — a
// module removed, or a typo the authoring form could not catch — is a
// thing a human fixes.
return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
}
// A dry run reports what it WOULD do and sends nothing (§I). Answering
// before the dispatch rather than inside the leg is what keeps that true
// for legs written by people who never read this file.
if (verify) return { ok: true }
const result = await registered.dispatch({
title: params.title || null,
excerpt: params.body,
image_url: null,
})
// The leg's own classification, not a second opinion. `retry` vs
// `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
// already makes, and making it twice is how the two drift.
const { outcome, error } = registered.classify(result)
if (outcome === 'done') return { ok: true }
return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
},
},
{
id: 'core.wait',
label: 'Wait',
description: 'Let a fixed amount of time pass before the next step of this phase runs.',
// `inspect` rather than `notify`: nothing is sent and nobody is told. It is
// the weakest class the closed set has for an action that is not a broadcast.
risk: 'inspect',
reversible: 'none',
version: 1,
params: [
{
name: 'seconds',
type: 'int',
required: true,
example: 300,
description: 'How long to wait. The runner sets the next step due_at from this.',
},
],
// A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
// NEXT step's `due_at`, which the runner owns, not something this function
// sleeps through. A `perform` that slept would hold a step's claim for the
// duration and turn a five-minute pause into a five-minute lease — and the
// reclaim would then re-dispatch it, so a long enough wait would never end.
//
// `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
// why the runner can honour this without knowing what `core.wait` is.
async perform({ params, verify }) {
if (verify) return { ok: true }
return { ok: true, holdFor: params.seconds }
},
},
{
id: 'core.cue',
label: 'Cue a human',
description:
'Post an instruction for staff and wait for someone to confirm it was done before the run advances.',
// The action itself only posts an instruction. Whatever the human then does
// is outside this system entirely, which is precisely why the cue exists:
// it is how an event uses a capability no module has automated.
risk: 'notify',
reversible: 'none',
version: 1,
params: [
{
name: 'instruction',
type: 'string',
required: true,
example: 'Open the north gate and read the herald script in Britain bank.',
description: 'What the staff member is being asked to do.',
},
{
name: 'assignee',
type: 'string',
required: false,
example: 'Event Team',
description: 'Who the cue is addressed to. A label, not an account.',
},
],
/**
* Post the instruction and PARK. The step does not complete here.
*
* `await: 'human'` is the envelope member that says so (org lead,
* 2026-09-02), and the runner's answer to it is to leave the step `running`
* with a NULL lease — genuinely in flight, nothing holding it, so the stale
* reclaim passes it by and a cue posted on Friday is still waiting on Monday.
* The step ends when someone presses confirm, which is Phase 3's control.
*
* **Nothing is delivered from here in Phase 2, and that is visible rather
* than pretended.** The instruction is carried by the step's own params and
* shown on the run console; routing it to Discord or to a staff inbox is
* Phase 10's integration work, through the engagement triggers that own every
* other notification on this platform. An action that grew its own delivery
* path would be the second one.
*/
async perform({ verify }) {
if (verify) return { ok: true }
return { ok: true, await: 'human' }
},
},
{
id: 'core.lease',
label: 'Borrow a value',
description:
'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.',
// The world changes and it changes back, so `change` rather than
// `irreversible` — and `change`'s default `on_failure` is `pause`, which is
// the right stop for a run that failed halfway through altering the world.
risk: 'change',
// The one action core ships in this class. `override` is what tells the
// cleanup sweep to restore through the LEASE registry rather than through an
// action's `revert()`, which is why this action needs no `revert()` of its own
// and why the registry refuses one on it.
reversible: 'override',
version: 1,
params: [
{
name: 'lease',
type: 'string',
required: true,
example: 'uo.rate.skillgain',
source: 'core.options.leases',
description: 'Which declared value to borrow.',
},
{
// **A string, and the coercion is here rather than in the type system.**
// A param declares ONE type; a lease declares its own, and they are four
// different ones. Typing this `float` would make a boolean lease
// unauthorable and a string lease nonsense, so the field takes text and
// this action turns it into whatever the named lease said it holds — the
// one place that knows both halves.
name: 'value',
type: 'string',
required: true,
example: '3.0',
description: 'What to hold it at, in whatever type the lease declares.',
},
{
// **Optional here, required by the LEASE** (Phase 12b), and the two are
// not the same statement. A param's `required` is a property of the
// action, and this action serves both a config key (which has no target)
// and an object property (which cannot be named without one) — so the
// field is declared optional and `perform` refuses a targeted lease with
// nothing in it, in the lease's own words.
//
// It carries no `source` for the same reason: the values behind it are
// the chosen LEASE's, and a param declares one source for all time. The
// lease's own `target.source` is what the authoring form reads once the
// author has picked a lease, which is the only moment the right list is
// knowable.
name: 'target',
type: 'string',
required: false,
example: '003f11b8-9bfa-4587-991e-ca263004efe6',
description: 'Which one, for a value that exists on many things. Leave empty otherwise.',
},
{
name: 'minutes',
type: 'int',
required: true,
example: 120,
description: 'How long to hold it. Core refuses more than the lease allows.',
},
],
// What a lease costs is the LEASE's business to bound, not a budget's:
// `maxDurationMs` and the numeric range are declared beside the callables and
// enforced below. A cap dimension here would be core inventing an accounting
// unit for something a module already bounds — and `registerEventBudgets`
// refuses a dimension nobody declared, which is exactly the rule that would
// then bite core's own action.
/**
* Read the baseline, reserve the target, apply the value.
*
* **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target
* is knowable before the dispatch — it is the lease id the step names — so
* the ledger row is written with its real `kind` and `ref` BEFORE anything
* touches the world, and the two-events-one-target refusal comes from the
* unique index at that moment rather than from a check that read and then
* wrote. A second run asking for a lease another run holds comes back
* `refused`, in the same words a cap breach uses and for the same reason:
* nothing is broken, the deployment already has that value spoken for.
*
* The order is read then reserve then apply, and a failure at each stage
* undoes the one before it: a reservation whose `apply` refuses is released
* here rather than left for the sweep, because there is nothing out there to
* give back and a shard that is merely down must not lock a lease out for the
* length of a retry cycle.
*/
async perform({ runId, stepId, params, verify }) {
// eslint-disable-next-line global-require
const resourcesDb = require('../model/events/eventRunResources.db')
const lease = registries.eventLease(params.lease)
if (!lease) {
return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` }
}
const coerced = coerceLeaseValue(lease, params.value)
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
// **The target is checked before anything else about the world is read**
// (Phase 12b), because both of its failures are authoring mistakes rather
// than outages: a targeted lease with no target names nothing, and a target
// on a lease that has none is an author who has confused two fields. Both
// are `retry: false` — the second attempt has the same params.
const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim()
if (lease.target && !targetRaw) {
return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` }
}
if (!lease.target && targetRaw) {
return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` }
}
const target = lease.target ? targetRaw : null
const ref = registries.leaseRef(lease.id, target)
// Refused rather than truncated, on the ledger's own rule for a resource
// ref: a truncated ref is a restore pointed at the wrong object.
if (ref.length > MAX_LEASE_REF) {
return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` }
}
const minutes = Number(params.minutes)
if (!Number.isFinite(minutes) || minutes <= 0) {
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
}
const ms = Math.round(minutes * 60_000)
if (ms > lease.maxDurationMs) {
return {
ok: false,
retry: false,
error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`,
}
}
// **The dry run stops here, and it has still checked everything worth
// checking**: the lease exists, the value is in range and the duration is
// allowed. What it deliberately does not do is reserve the target — a
// verify that took a lease would be a dry run that changed something, and
// it would then refuse the real run that followed it.
//
// It also does not check that the TARGET exists, and that is the same
// rule rather than an exception: asking the game side whether a spawner is
// there is a live read the shard may be down for, and a dry run that fails
// because a shard is restarting would make `verified_at` a property of the
// moment rather than of the version (§K).
if (verify) return { ok: true }
const baseline = await lease.read({ target })
if (!baseline || baseline.ok !== true) {
return {
ok: false,
error: baseline && baseline.error
? String(baseline.error)
: `could not read the current value of ${lease.label}`,
}
}
const until = new Date(Date.now() + ms)
const reserved = await resourcesDb.reserve({
runId,
stepId,
owner: lease.owner || 'core',
kind: 'override',
// **The ref carries the target, and that is what makes the unique index
// right rather than merely present.** Reserved under the lease id alone,
// an event turning up one spawner would lock every other run out of every
// other spawner — a conflict check that refuses correct work is as wrong
// as one that permits a collision, and on a shard with 6,707 spawners it
// is the failure an operator would actually meet.
ref,
payload: {
target: ref,
leaseTarget: target,
baseline: baseline.value,
applied: coerced.value,
until: until.toISOString(),
},
leaseUntil: until,
})
if (!reserved.ok) {
const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : ''
return {
ok: false,
retry: false,
error: `${lease.label} is already leased by another run${heldBy}`,
}
}
// **`until` goes down the wire** (§F). The module passes it to its sidecar
// and the game side restores baseline when it passes, without being asked
// again — the fail-safe that makes an unattended, scheduled world change
// defensible, because the worst case is a world back at baseline early
// rather than one stuck changed indefinitely.
let applied
try {
applied = await lease.apply(coerced.value, until, { target })
} catch (err) {
applied = { ok: false, error: err.message }
}
if (!applied || applied.ok !== true) {
await resourcesDb.markReverted(reserved.id)
return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` }
}
await resourcesDb.confirm(reserved.id)
// **The run now owes the world something, and something has to say so.**
// The generic path marks a run dirty when it records a module's reported
// resources; this action reserves its own row and never goes through it, so
// a run whose only resource was a lease would have kept `cleanup_status =
// 'not_required'` and never been swept. Found by the live walk, and the
// cleanup leg's own scan was widened to make the class impossible rather
// than only this instance.
// eslint-disable-next-line global-require
await require('../events/ledger').markRunDirty(runId)
return { ok: true }
},
/**
* Which of this run's leases the game side still has a record of (Phase 11b).
*
* **A lease row had no reconcile path at all until this existed**, and nothing
* failed to say so. `cleanup.js` resolves a resource to the action of the step
* that made it, and for a lease that action is `core.lease` — a CORE action, on
* a path a module cannot register anything on. So every `override` row came
* back `unanswered` for the life of the run, and a lease the shard had quietly
* dropped (a restart reverts every config lease, by design) stayed in the
* ledger as live until teardown went looking for a baseline nobody was holding.
*
* The question asked is deliberately NOT "is the value still what we applied".
* That is drift, and drift is teardown's verdict to deliver through `restore`
* so the row lands as `drifted` with the current value beside it. A reconcile
* that inferred absence from a changed value would orphan the row first and
* throw that away — the operator would be told the lease vanished rather than
* that somebody moved it.
*
* A lease with no `inForce()` is reported in force, which is core's posture
* everywhere else in this file: "I could not ask" must never be recorded as
* "it is gone".
*/
async reconcile({ resources }) {
const inForce = []
for (const row of resources || []) {
if (row.kind !== 'override') continue
// Resolved through the ref parser rather than by a bare map lookup: a
// targeted row's ref is `<id>#<target>` and `eventLease` would miss it,
// which would silently report every property lease still in force.
const found = registries.eventLeaseForRef(row.ref)
const lease = found && found.lease
if (!lease || typeof lease.inForce !== 'function') {
inForce.push(row.ref)
continue
}
let answer
try {
answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null })
} catch (err) {
answer = null
}
// Only an explicit `held: false` takes a row out. A module that threw, timed
// out, or answered something unrecognisable has not said the lease is gone.
if (answer && answer.ok === true && answer.held === false) continue
inForce.push(row.ref)
}
return { ok: true, inForce }
},
},
{
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) ──────────────────
//
// One, and it is core's half of the seam it hands a module on the same boot: a
// param's `source` names a registered option source, core asks it for values, and
// the authoring form renders a dropdown instead of a text box.
//
// **The legs are already a registry with labels in it**, so this costs nothing
// new — which is what makes it the right first exercise. `resolve()` is called
// per request rather than read once, for the same reason `core.announce` looks a
// leg up inside `perform()`: a leg registered by a module that booted after this
// file was evaluated must still appear, and a module uninstalled since must stop
// appearing.
const OPTION_SOURCES = [
{
id: 'core.options.legs',
label: 'Announce legs',
description: 'Every delivery leg registered on this deployment right now.',
async resolve() {
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
},
},
{
id: 'core.options.leases',
label: 'Borrowable values',
description: 'Every value a module has declared this deployment may lease.',
async resolve() {
return registries
.allEventLeases()
.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 }