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
756 lines
34 KiB
JavaScript
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 }
|