`utils/eventRunner.js`, the eighth poller, wired into server.js beside engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their grace window into `missed`, advances each due run through its phases, and drains that phase's steps in `seq` order. The three core actions from Phase 1 get real bodies, so a published event started from the existing run route now announces, waits and completes on its own. No routes are added: a runner has no surface, and the live controls stay Phase 3's. Four things the org lead settled (2026-09-02): a parked step is `running` with a NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members rather than special cases keyed on an action id; a run whose concurrency key is held stays `scheduled` and lets its grace window decide; and `n` in §L's `retry(n)` is a runner constant. Co-Authored-By: Claude <noreply@anthropic.com>
213 lines
9.1 KiB
JavaScript
213 lines
9.1 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 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.
|
|
|
|
const registries = require('../modules/registries')
|
|
|
|
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.
|
|
name: 'leg',
|
|
type: 'string',
|
|
required: true,
|
|
example: 'discord',
|
|
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' }
|
|
},
|
|
},
|
|
]
|
|
|
|
module.exports = { ACTIONS }
|