feat(events): the runner (Phase 2)
`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>
This commit is contained in:
@@ -13,28 +13,20 @@
|
||||
// a human to go and do something. A deployment with no game module installed has
|
||||
// a working event system made of exactly these.
|
||||
//
|
||||
// **Nothing here dispatches yet.** Phase 1 builds the registry, the id grammar,
|
||||
// the risk classes and the param validation; Phase 2 builds `utils/eventRunner.js`
|
||||
// and is what calls `perform()`. The bodies below therefore answer with the
|
||||
// envelope §F defines for a refusal — and specifically NOT with `{ ok: true }`,
|
||||
// which is the one wrong answer a placeholder can give: `ok: true` on an action
|
||||
// that did nothing is a recorded world change that did not occur, which is the
|
||||
// exact mistake the envelope's failure default exists to prevent. `retry: false`
|
||||
// because a missing runner is not a transient condition.
|
||||
// **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). It is pure data plus three functions that are not called.
|
||||
// (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.
|
||||
|
||||
// A factory rather than one shared function, because `perform`'s argument is
|
||||
// §F's dispatch envelope — `{ runId, stepId, idempotencyKey, scope, params,
|
||||
// actor, verify }` — and it does not carry the action's own id. Closing over it
|
||||
// is what lets the refusal name which action refused.
|
||||
const notWiredYet = (actionId) => async () => ({
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${actionId} is declared in Phase 1 and dispatched from Phase 2`,
|
||||
})
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
@@ -81,7 +73,52 @@ const ACTIONS = [
|
||||
},
|
||||
],
|
||||
|
||||
perform: notWiredYet('core.announce'),
|
||||
/**
|
||||
* 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` }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -105,11 +142,18 @@ const ACTIONS = [
|
||||
},
|
||||
],
|
||||
|
||||
// A wait is a genuine no-op at dispatch, and it will stay 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.
|
||||
perform: notWiredYet('core.wait'),
|
||||
// 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 }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -142,11 +186,26 @@ const ACTIONS = [
|
||||
},
|
||||
],
|
||||
|
||||
// Phase 2 gives this its parking semantics — a cue step does not complete
|
||||
// when `perform` answers, it completes when a human presses confirm, and the
|
||||
// control that does so is Phase 3's. Both of those are what make this the
|
||||
// one action whose runtime shape is deliberately not decided here.
|
||||
perform: notWiredYet('core.cue'),
|
||||
/**
|
||||
* 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' }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user