Files
website/server/test/eventActionRegistry.test.js
wtclaude 2e964cfeee
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 5m29s
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>
2026-09-02 06:32:24 -05:00

250 lines
10 KiB
JavaScript

// ── The event action registry (EVENTS.md §F, Phase 1) ──────────────────────
//
// Phase 1's acceptance criteria for the registry half, one test apiece:
//
// • core's three actions register on every boot and appear in the catalog
// • the catalog never carries a callable — no `perform`, `revert` or `cost`
// • a module registering an un-namespaced action fails, with the holder named
// • the closed sets are closed: an invented risk or reversibility is refused
// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION,
// not discovered at teardown when something has already been created
// • an action id and a trigger id are DIFFERENT namespaces, so one id may
// legitimately be both — the property the audience registry established and
// this one inherits
//
// Point the DB at a closed port BEFORE requiring anything: registries.js reaches
// utils/discordAnnounce, which reaches the pool at require time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const coreEventActions = require('../src/config/coreEventActions')
const db = require('../src/utils/db')
after(() => db.close())
beforeEach(() => registries._reset())
afterEach(() => registries._reset())
const ok = (over = {}) => ({
id: 'demo.thing.do',
label: 'Do the thing',
risk: 'change',
reversible: 'none',
perform: async () => ({ ok: true }),
...over,
})
const register = (owner, entries) => {
const api = registries.stage(owner)
api.registerEventActions(entries)
registries.apply(api.staged)
}
test('core registers its three actions on every boot', () => {
registries.registerCore()
const ids = registries.allEventActions().map((a) => a.id)
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue'])
assert.equal(ids.length, coreEventActions.ACTIONS.length)
})
test('the catalog carries no callable', () => {
registries.registerCore()
for (const action of registries.allEventActions()) {
assert.equal(action.perform, undefined, `${action.id} leaked perform`)
assert.equal(action.revert, undefined, `${action.id} leaked revert`)
assert.equal(action.cost, undefined, `${action.id} leaked cost`)
}
// …and the runner's own lookup still has it, which is the half that makes the
// stripping a boundary rather than a deletion.
assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
})
test('core.announce refuses an unregistered leg terminally, and never claims success', async () => {
// Phase 1's version of this test asserted that all three core actions REFUSED,
// because none of them was wired yet. Phase 2 gave them real bodies, so what
// survives is the half that was never about the placeholder: `ok: true` on an
// action that did nothing is a recorded world change that did not occur.
//
// `core.announce` is the one that can still legitimately refuse. A leg nobody
// registers will not appear between two attempts a minute apart, so the answer
// is terminal rather than transient — a human has to fix it.
registries.registerCore()
const answer = await registries.eventAction('core.announce').perform({
params: { leg: 'nowhere', body: 'hello' },
})
assert.equal(answer.ok, false)
assert.equal(answer.retry, false)
assert.match(answer.error, /nowhere/)
})
test('a dry run validates and reports, but dispatches nothing', async () => {
// §I's dry run: `verify === true` means validate and report, change nothing.
// `core.announce` is the only core action with an outside effect to suppress.
//
// **The leg is resolved BEFORE `verify` is honoured, and that ordering is the
// point rather than an oversight.** A dry run exists to report what would
// happen, and "this step names a leg nobody registers" is the most useful thing
// it can find. Answering `ok: true` first would make the dry run pass on
// exactly the definition that cannot work.
registries.registerCore()
const announce = registries.eventAction('core.announce')
const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true })
assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist')
// A registered leg: reported good, and its transport never touched.
const leg = registries.announceLeg('discord')
const dispatch = leg.dispatch
let dispatched = 0
leg.dispatch = async () => {
dispatched += 1
return { ok: true }
}
try {
const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true })
assert.equal(good.ok, true)
assert.equal(dispatched, 0, 'a dry run sends nothing')
} finally {
leg.dispatch = dispatch
}
})
test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => {
// Both answer through ordinary envelope members, which is what lets the runner
// honour them without knowing what either action is. A `perform` that slept
// would hold its claim for the duration and turn a five-minute pause into a
// five-minute lease.
registries.registerCore()
assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), {
ok: true,
holdFor: 300,
})
assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), {
ok: true,
await: 'human',
})
})
test('an action must be namespaced to its owner, and the holder is named', () => {
assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/)
register('demo', [ok()])
assert.throws(
() => register('rival', [ok({ id: 'demo.thing.do' })]),
/already registered by "demo"/,
)
})
test('the same id twice in one batch is refused', () => {
assert.throws(() => register('demo', [ok(), ok()]), /registered twice/)
})
test('risk and reversibility are closed sets with no default', () => {
assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/)
assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/)
assert.throws(
() => register('demo', [ok({ reversible: undefined })]),
/needs a reversible class/,
)
assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/)
})
test("reversible: 'ledger' without revert() is refused at registration", () => {
assert.throws(
() => register('demo', [ok({ reversible: 'ledger' })]),
/is reversible: 'ledger' but has no revert\(\)/,
)
// And the mirror: a revert() nothing will ever call is a promise core does not
// keep, so it is refused just as loudly.
assert.throws(
() => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]),
/declares revert\(\) but is reversible: 'none'/,
)
register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })])
assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function')
})
test('perform() is required and cost must be a function', () => {
assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/)
assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
})
test('every param needs a type and an example', () => {
const withParams = (params) => ok({ params })
assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/)
assert.throws(
() => register('demo', [withParams([{ name: 'x', type: 'int' }])]),
/needs an example/,
)
assert.throws(
() => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]),
/bad param name/,
)
assert.throws(
() =>
register('demo', [
withParams([
{ name: 'x', type: 'int', example: 1 },
{ name: 'x', type: 'int', example: 2 },
]),
]),
/declared twice/,
)
register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])])
const [param] = registries.eventAction('demo.thing.do').params
assert.deepEqual(param, {
name: 'x',
type: 'int',
required: false,
example: 12,
source: 'demo.options.x',
description: '',
})
})
test('budgetMs defaults, and is bounded', () => {
register('demo', [ok()])
assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS)
registries._reset()
assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/)
assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/)
})
test('actions and triggers are different namespaces, so one id may be both', () => {
// The property §F states and the audience registry established first. A verb
// called `demo.raid.start` and an event called `demo.raid.start` are two
// unrelated declarations, and forbidding the pair would forbid the most
// natural names a module will ever want.
const api = registries.stage('demo')
api.registerEventTriggers([
{ id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' },
])
api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })])
registries.apply(api.staged)
assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started')
assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid')
})
test('a whole batch is refused or taken, never half', () => {
assert.throws(
() => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]),
/needs a risk class/,
)
// The shape check throws at the CALL, before anything is staged, so nothing
// from the batch is visible.
assert.equal(registries.eventAction('demo.thing.do'), null)
})
test('_reset() hands the process back', () => {
registries.registerCore()
assert.equal(registries.allEventActions().length, 3)
registries._reset()
assert.equal(registries.allEventActions().length, 0)
assert.equal(registries.isEventAction('core.wait'), false)
})