Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN. One table, one core action, one route, one body field, and two members added to MODULE_API 1.10.0 in place. The safety property the whole world-write half depends on: core now remembers what a run changed in the world, and gives it back on every terminal path. Four decisions settled by the org lead on 2026-09-03, all as recommended: - A lease is acquired by a new CORE action, `core.lease`. Section F puts the duration bound and the two-events-one-target conflict check on core's side of the seam, and a lease verb per module would be both re-implemented once per module, advisory everywhere. - Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A spawn's ref does not exist until the module answers, so what core writes before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never comes it stands, and cleanup calls revert() with the key and no resources -- which is why section F's revert takes the key at all. - Cleanup is one sweep over the ledger, not synthetic step rows. The step-shaped version costs a second retry counter beside `revert_attempts`. - `reconcile` is declared here and TRIGGERED BY THE MODULE, through `ctx.events.reconcile()`. Core has no concept of the game being up, so it cannot decide when to ask; it asks once at its own boot. MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`; while it is on `edge` it is amended in place, so the whole module contract reaches an author as one version they read once. Verify - `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the pre-existing engagementManifest CRLF failure, in a file this branch does not touch (`edge` before: 1950/1876/73/1). +75 tests. - The unique key was proved against a REAL MariaDB, because nothing else can prove it: whether multiple NULLs collide in a unique index, whether a STORED generated column is recomputed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the server. eventRunnerSql.test.js gained 16 tests; 65 pass against the container. The real schema.sql was applied to a fresh database and to an existing one. - Client: 362 pass, and it builds. routes:manifest and swagger -- one route added, none moved. The live walk found three defects, and two of them are the phase's real finding Driven by a throwaway `rig` module in website/modules/, deleted before commit. 1. A lease was never given back at all. `core.lease` reserves its own ledger row, so it never went through the ledger's dirty-marking, so a run holding only a lease kept `cleanup_status = 'not_required'` and the cleanup leg -- which selected on `pending` -- never looked at it. 2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing sweep moved the run to `incomplete`, which took it out of the leg's own scan for ever. The test covering the bound asserted `<= 3` and was satisfied by 1: a bound has two halves, and a test that only asserts the ceiling passes against a floor. 3. The first fix for (2) made the console lie. Spending every row's `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and the run page then rendered "3 attempts" beside resources nothing had ever tried. Found by opening the page. Both (1) and (2) are the same mistake: deriving "is there anything to do" from a summary column instead of from the rows. Neither was visible to a unit test, because a test that calls the sweep directly never asks what would have selected the run. The two properties that need the process to die were walked as the plan asks. With the module's perform() hanging, the placeholder existed while the dispatch was in flight and nothing was named; after taskkill and a restart the reclaim re-dispatched the same idempotency key, the retry re-used its own placeholder, and everything was given back. Then, with the module reporting one of two resources as no longer in force, the boot-time reconcile marked the other `orphaned` -- never `reverted`. This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json. Co-Authored-By: Claude <noreply@anthropic.com>
755 lines
32 KiB
JavaScript
755 lines
32 KiB
JavaScript
// ── The module contract, proved with a module (EVENTS_PLAN.md Phase 7) ─────
|
||
//
|
||
// §F is the seam this phase opens: `registerEventActions`, `registerEventBudgets`,
|
||
// `registerEventLeases` and `registerEventOptionSources`, at MODULE_API 1.10.0.
|
||
// Every one of them was reachable only by `registerCore()` before this phase, and
|
||
// the plan is explicit about how to prove they are reachable now:
|
||
//
|
||
// > **Prove it with a throwaway module, not with module-uo.** A contract
|
||
// > validated only against the module it was carved out of has not been
|
||
// > validated, and P9 should be the *second* consumer of this seam.
|
||
//
|
||
// So every test below writes a real module to a real directory, points
|
||
// MODULES_DIR at it and lets the real loader scan, validate, `register()` and
|
||
// commit it. Nothing here stubs the loader or calls `registries.stage()` by hand:
|
||
// a test that staged directly would pass just as happily against the code before
|
||
// this phase, when `buildApi` forwarded none of these four names.
|
||
//
|
||
// **The half this file cares most about is the failure half.** §F's load-bearing
|
||
// envelope rule is that *no shape a failure can take may read as success* — a
|
||
// rejected promise, a throw, a timeout, a non-object and a missing `ok` are all
|
||
// `{ ok: false, retry: true }`, which is `registerTeamProvider`'s default
|
||
// inverted, because the expensive mistake here is recording a world change that
|
||
// did not happen. Those five shapes are dispatched from a module below, not
|
||
// constructed as literals, so what is under test is the path a module actually
|
||
// takes.
|
||
//
|
||
// The pool points at a closed port before anything is required: the loader's
|
||
// `buildCtx` pulls in the models, which build a mariadb pool at require time. No
|
||
// query is ever run.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const fs = require('fs')
|
||
const os = require('os')
|
||
const path = require('path')
|
||
|
||
const { test, beforeEach, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const express = require('express')
|
||
|
||
const db = require('../src/utils/db')
|
||
const registries = require('../src/modules/registries')
|
||
const dispatch = require('../src/events/dispatch')
|
||
const authorize = require('../src/events/authorize')
|
||
const { MODULE_API_VERSION } = require('../src/modules/version')
|
||
|
||
after(() => db.close())
|
||
|
||
let tmpRoot
|
||
|
||
const emptyTiers = () => ({
|
||
public: express.Router(),
|
||
admin: express.Router(),
|
||
player: express.Router(),
|
||
})
|
||
|
||
/**
|
||
* Write a module and let the real loader scan it.
|
||
*
|
||
* MODULES_DIR is read into a const at require time — it has to be, the scan is
|
||
* synchronous and happens while app.js is being required — so busting the cache
|
||
* is the only honest way to point the loader somewhere else.
|
||
*/
|
||
function loadModule(id, source, manifest = {}) {
|
||
const dir = path.join(tmpRoot, id)
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
fs.writeFileSync(
|
||
path.join(dir, 'module.json'),
|
||
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.10.0', server: 'index.js', ...manifest }),
|
||
)
|
||
fs.writeFileSync(path.join(dir, 'index.js'), source)
|
||
|
||
process.env.MODULES_DIR = tmpRoot
|
||
registries._reset()
|
||
delete require.cache[require.resolve('../src/modules/loader')]
|
||
// eslint-disable-next-line global-require
|
||
const loader = require('../src/modules/loader')
|
||
loader.load(emptyTiers())
|
||
return loader.list().find((m) => m.id === id)
|
||
}
|
||
|
||
/** The state a module that loaded cleanly is in, with its reason if it did not. */
|
||
const assertRegistered = (record) => {
|
||
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
|
||
}
|
||
|
||
const RUN = { id: 1, scope: '' }
|
||
const step = (actionId, params = {}) => ({
|
||
id: 1,
|
||
action_id: actionId,
|
||
params,
|
||
idempotency_key: 'k-1',
|
||
})
|
||
|
||
beforeEach(() => {
|
||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
|
||
})
|
||
|
||
// ── The seam is open ───────────────────────────────────────────────────────
|
||
|
||
test('the version a module declares against is 1.10.0', () => {
|
||
// Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
|
||
// these tests fail loudly rather than quietly if the bump is ever reverted —
|
||
// the loader would refuse the manifest and every assertion would become "the
|
||
// module did not register", which is the same failure the seam closing would
|
||
// produce. Asserting the number here says which of the two it was.
|
||
assert.equal(MODULE_API_VERSION, '1.10.0')
|
||
})
|
||
|
||
test('a module registers actions, budgets, leases and option sources', () => {
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([
|
||
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' },
|
||
])
|
||
api.registerEventActions([{
|
||
id: 'demo.wisp.summon',
|
||
label: 'Summon wisps',
|
||
risk: 'change',
|
||
reversible: 'ledger',
|
||
cost: (p) => ({ 'demo.wisps': p.count }),
|
||
params: [{ name: 'count', type: 'int', required: true, example: 3 }],
|
||
async perform() { return { ok: true, resources: [{ kind: 'wisp', ref: '0x1' }] } },
|
||
async revert() { return { ok: true } },
|
||
}])
|
||
api.registerEventLeases([{
|
||
id: 'demo.rate.gain',
|
||
label: 'Gain rate',
|
||
type: 'float',
|
||
min: 0.5,
|
||
max: 5,
|
||
maxDurationMs: 3600000,
|
||
async read() { return { ok: true, value: 1 } },
|
||
async apply() { return { ok: true } },
|
||
async restore() { return { ok: true } },
|
||
}])
|
||
api.registerEventOptionSources([{
|
||
id: 'demo.options.hues',
|
||
label: 'Hues',
|
||
async resolve() { return [{ value: '1157', label: 'Blood', group: 'Reds' }] },
|
||
}])
|
||
}`)
|
||
|
||
assertRegistered(record)
|
||
assert.equal(registries.eventAction('demo.wisp.summon').owner, 'demo')
|
||
assert.deepEqual(registries.eventBudget('demo.wisps'), {
|
||
owner: 'demo', id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', description: '',
|
||
})
|
||
assert.equal(registries.eventLease('demo.rate.gain').maxDurationMs, 3600000)
|
||
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['demo.options.hues'])
|
||
})
|
||
|
||
test('the catalog never carries a callable, whichever registration it came from', async () => {
|
||
// The rule every registry in this file already keeps, restated for four new
|
||
// shapes at once: these objects LEAVE THE PROCESS, and the browser's whole
|
||
// relationship with any of them is naming one by id. A `perform` or a
|
||
// `resolve` riding out would make §F's "a module registers actions server-side
|
||
// and adds no routes for them" false in the one direction nobody would notice.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.a', label: 'A', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
api.registerEventLeases([{
|
||
id: 'demo.l', label: 'L', type: 'bool', maxDurationMs: 1000,
|
||
async read() { return { ok: true } },
|
||
async apply() { return { ok: true } },
|
||
async restore() { return { ok: true } },
|
||
}])
|
||
api.registerEventOptionSources([
|
||
{ id: 'demo.o', label: 'O', async resolve() { return [] } },
|
||
])
|
||
}`))
|
||
|
||
for (const a of registries.allEventActions()) {
|
||
assert.equal(a.perform, undefined)
|
||
assert.equal(a.revert, undefined)
|
||
assert.equal(a.cost, undefined)
|
||
}
|
||
for (const l of registries.allEventLeases()) {
|
||
assert.equal(l.read, undefined)
|
||
assert.equal(l.apply, undefined)
|
||
assert.equal(l.restore, undefined)
|
||
}
|
||
for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
|
||
})
|
||
|
||
test('a second call is a module changing its mind, and it fails the module alone', () => {
|
||
// `once`, on all four, for the reason every batch registration takes it: a
|
||
// batch is a module's complete statement about what it declares. And the
|
||
// failure is the loader's §4.4 guarantee — recorded against the module, the
|
||
// site still up, nothing committed.
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'demo.a', label: 'A', unit: 'count' }])
|
||
api.registerEventBudgets([{ id: 'demo.b', label: 'B', unit: 'count' }])
|
||
}`)
|
||
assert.equal(record.state, 'startup_failed')
|
||
assert.match(record.reason, /registerEventBudgets\(\) called twice/)
|
||
// Validate-then-commit, per registrant: the FIRST batch is gone too.
|
||
assert.equal(registries.eventBudget('demo.a'), null)
|
||
})
|
||
|
||
test('a module may not name a budget outside its own prefix', () => {
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'uo.creatures', label: 'Creatures', unit: 'count' }])
|
||
}`)
|
||
assert.equal(record.state, 'startup_failed')
|
||
assert.match(record.reason, /not namespaced "demo\."/)
|
||
})
|
||
|
||
test('a module that registers nothing at all is a module, not a failure', () => {
|
||
// §F states it once because it governs every member: everything a module
|
||
// registers is optional, and core with none of it is still an event engine
|
||
// that can announce, wait, cue a human and publish results.
|
||
const record = loadModule('quiet', 'module.exports = () => {}')
|
||
assertRegistered(record)
|
||
assert.deepEqual(registries.allEventBudgets(), [])
|
||
assert.deepEqual(registries.allEventActions().map((a) => a.id), [])
|
||
})
|
||
|
||
// ── The envelope: no failure shape reads as success ────────────────────────
|
||
|
||
test('every shape a module failure can take is dispatched as a retry, not a success', async () => {
|
||
// The five §F names, answered by a real module through the real dispatcher.
|
||
// Written as one module with five verbs rather than five modules, because what
|
||
// is under test is the CLASSIFIER and a module per shape would be four extra
|
||
// loader scans saying nothing.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
const verb = (id, perform) => ({
|
||
id, label: id, risk: 'notify', reversible: 'none', budgetMs: 200, perform,
|
||
})
|
||
api.registerEventActions([
|
||
verb('demo.rejects', async () => { return Promise.reject(new Error('the socket went away')) }),
|
||
verb('demo.throws', async () => { throw new Error('a typo in the module') }),
|
||
verb('demo.hangs', () => new Promise(() => {})),
|
||
verb('demo.lies', async () => 'fine'),
|
||
verb('demo.forgets', async () => ({ resources: [] })),
|
||
])
|
||
}`))
|
||
|
||
for (const id of ['demo.rejects', 'demo.throws', 'demo.hangs', 'demo.lies', 'demo.forgets']) {
|
||
const result = await dispatch.dispatchStep(step(id), { run: RUN })
|
||
assert.equal(result.outcome, 'retry', `${id} must not read as success`)
|
||
assert.ok(result.error, `${id} must say what went wrong`)
|
||
}
|
||
})
|
||
|
||
test('a module that means "never" says so, and only then is it terminal', async () => {
|
||
// The inverse of the rule above, and the reason `retry` is opted OUT of rather
|
||
// than into: an envelope that forgot to say anything gets the benefit of the
|
||
// doubt on the transient question, so only a module that states `retry: false`
|
||
// gets a step marked as never going to work.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([
|
||
{ id: 'demo.never', label: 'Never', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: false, retry: false, error: 'there is no such gate' } } },
|
||
{ id: 'demo.later', label: 'Later', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: false, error: 'the shard is restarting' } } },
|
||
])
|
||
}`))
|
||
|
||
const never = await dispatch.dispatchStep(step('demo.never'), { run: RUN })
|
||
assert.equal(never.outcome, 'terminal')
|
||
assert.equal(never.error, 'there is no such gate')
|
||
|
||
const later = await dispatch.dispatchStep(step('demo.later'), { run: RUN })
|
||
assert.equal(later.outcome, 'retry')
|
||
})
|
||
|
||
test('budgetMs is the contract’s deadline, and a wedged module does not hold the tick', async () => {
|
||
// Declared by the module, bounded by the registry, enforced by the dispatcher.
|
||
// Without it a `perform()` awaiting a socket that never answers holds the
|
||
// step's claim until the lease expires and the reclaim re-dispatches it, which
|
||
// is how one wedged sidecar becomes an infinite loop rather than a failed step.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.hangs', label: 'Hangs', risk: 'notify', reversible: 'none',
|
||
budgetMs: 120,
|
||
perform: () => new Promise(() => {}),
|
||
}])
|
||
}`))
|
||
|
||
const started = Date.now()
|
||
const result = await dispatch.dispatchStep(step('demo.hangs'), { run: RUN })
|
||
assert.equal(result.outcome, 'retry')
|
||
assert.match(result.error, /exceeded its 120ms budget/)
|
||
assert.ok(Date.now() - started < 2000, 'the runner stopped waiting long before any lease would expire')
|
||
})
|
||
|
||
test('the two success shapes that mean "not finished" reach a module through the same door', async () => {
|
||
// §F, and the org lead's 2026-09-02 decision: both are ordinary envelope
|
||
// members rather than special cases keyed on an action id, so the runner never
|
||
// names a verb — and a module's own long-running action gets `await: 'human'`
|
||
// and `holdFor` for free, exactly as `core.cue` and `core.wait` do.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([
|
||
{ id: 'demo.parks', label: 'Parks', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true, await: 'human' } } },
|
||
{ id: 'demo.holds', label: 'Holds', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true, holdFor: 300 } } },
|
||
])
|
||
}`))
|
||
|
||
const parked = await dispatch.dispatchStep(step('demo.parks'), { run: RUN })
|
||
assert.equal(parked.outcome, 'parked')
|
||
|
||
const held = await dispatch.dispatchStep(step('demo.holds'), { run: RUN })
|
||
assert.equal(held.outcome, 'done')
|
||
assert.equal(held.holdSeconds, 300)
|
||
})
|
||
|
||
// ── verify: true is a parameter a module must honour ───────────────────────
|
||
|
||
test('verify rides through to the module unchanged, and a dry run changes nothing', async () => {
|
||
// §F: *"`verify: true` must change nothing and must answer honestly"*. Core
|
||
// cannot enforce the first half — only the module knows what its own writes
|
||
// are — so what IS testable is that the flag arrives, and that it arrives down
|
||
// the same dispatcher the real run uses. A dry run down a second code path is
|
||
// a dry run of the second path.
|
||
const written = []
|
||
global.__rgDemoWrites = written
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.writes', label: 'Writes', risk: 'change', reversible: 'none',
|
||
async perform({ verify, params }) {
|
||
if (verify) return { ok: true, wouldWrite: params.what }
|
||
global.__rgDemoWrites.push(params.what)
|
||
return { ok: true }
|
||
},
|
||
}])
|
||
}`))
|
||
|
||
const dry = await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), {
|
||
run: RUN,
|
||
verify: true,
|
||
})
|
||
assert.equal(dry.outcome, 'done')
|
||
assert.deepEqual(written, [], 'a dry run wrote something')
|
||
|
||
await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), { run: RUN })
|
||
assert.deepEqual(written, ['a gate'])
|
||
delete global.__rgDemoWrites
|
||
})
|
||
|
||
test('the whole envelope reaches the module, idempotency key included', async () => {
|
||
// The key is the module's half of a retry that is safe on the game side, and a
|
||
// module cannot pass it down its own wire if core does not hand it over. It is
|
||
// asserted here rather than in the runner's tests because THIS is the surface
|
||
// a module author reads.
|
||
global.__rgDemoEnvelope = null
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.echo', label: 'Echo', risk: 'notify', reversible: 'none',
|
||
async perform(envelope) { global.__rgDemoEnvelope = envelope; return { ok: true } },
|
||
}])
|
||
}`))
|
||
|
||
await dispatch.dispatchStep(step('demo.echo', { a: 1 }), { run: { id: 7, scope: 'atlantic' } })
|
||
const envelope = global.__rgDemoEnvelope
|
||
assert.deepEqual(envelope, {
|
||
runId: 7,
|
||
stepId: 1,
|
||
idempotencyKey: 'k-1',
|
||
scope: 'atlantic',
|
||
params: { a: 1 },
|
||
actor: null,
|
||
verify: false,
|
||
})
|
||
delete global.__rgDemoEnvelope
|
||
})
|
||
|
||
// ── Budgets: a module cannot spend what it did not declare ─────────────────
|
||
|
||
test('a cost naming a dimension the module never declared is refused', async () => {
|
||
// §F, fail closed (org lead, 2026-09-03). The module here is not malicious and
|
||
// not exotic — it is one that declared two budgets and priced a third, which is
|
||
// what a rename looks like. The refusal has its own code because the fix is a
|
||
// module's declaration, not a deployment's cap.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
||
api.registerEventActions([{
|
||
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||
cost: () => ({ 'demo.wraiths': 2 }),
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
}`))
|
||
|
||
// `settings: null` because this file has no database: passing it explicitly is
|
||
// what tells `mayInvoke` not to go and read the switchboard row, and the
|
||
// enablement layer answers before the cap layer either way.
|
||
const verdict = await authorize.mayInvoke({
|
||
action: registries.eventAction('demo.summon'),
|
||
params: {},
|
||
settings: null,
|
||
})
|
||
assert.equal(verdict.ok, false)
|
||
assert.equal(verdict.code, 'undeclared')
|
||
assert.match(verdict.reason, /demo\.wraiths/)
|
||
})
|
||
|
||
test('a declared dimension is priced, named and unbounded until an operator says otherwise', async () => {
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' }])
|
||
api.registerEventActions([{
|
||
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||
cost: (p) => ({ 'demo.wisps': p.count }),
|
||
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
}`))
|
||
|
||
// What the switchboard renders: the id the action prices, dressed with what the
|
||
// module called it. Discovered by pricing the declared example, which is why
|
||
// §F makes `example` required on every param.
|
||
assert.deepEqual(authorize.budgetsOf(registries.eventAction('demo.summon')), [
|
||
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', registered: true },
|
||
])
|
||
|
||
// And with no settings row and no run, the answer is yes: a declared budget is
|
||
// a dimension that can be counted, not a bound that has been set.
|
||
const verdict = await authorize.mayInvoke({
|
||
action: registries.eventAction('demo.summon'),
|
||
params: { count: 4 },
|
||
settings: null,
|
||
})
|
||
assert.deepEqual(verdict, { ok: true, cost: { 'demo.wisps': 4 } })
|
||
})
|
||
|
||
// ── Option sources ─────────────────────────────────────────────────────────
|
||
|
||
test('a module answers its own option source, and core normalises what comes back', async () => {
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventOptionSources([{
|
||
id: 'demo.options.hues', label: 'Hues',
|
||
async resolve() {
|
||
return [
|
||
{ value: 1157, label: 'Blood', group: 'Reds' },
|
||
{ value: '2213', label: 'Ice' },
|
||
{ value: '', label: 'a blank nobody can pick' },
|
||
'not an option at all',
|
||
]
|
||
},
|
||
}])
|
||
}`))
|
||
|
||
const answer = await registries.resolveOptionSource('demo.options.hues')
|
||
assert.equal(answer.ok, true)
|
||
assert.equal(answer.owner, 'demo')
|
||
// Coerced to strings, because this array is rendered into a `<select>` and
|
||
// submitted back as text; and the two entries that could not become an option
|
||
// are dropped rather than becoming one that submits "undefined".
|
||
assert.deepEqual(answer.options, [
|
||
{ value: '1157', label: 'Blood', group: 'Reds' },
|
||
{ value: '2213', label: 'Ice' },
|
||
])
|
||
})
|
||
|
||
test('every way an option source can fail degrades the field rather than breaking it', async () => {
|
||
// §F: a refusal degrades the field to free text with a visible warning. So none
|
||
// of these throws, and each says something an operator can act on — an
|
||
// authoring form a shard outage can make unusable is a worse failure than the
|
||
// typo the dropdown exists to prevent.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventOptionSources([
|
||
{ id: 'demo.options.throws', label: 'Throws', async resolve() { throw new Error('sidecar down') } },
|
||
{ id: 'demo.options.lies', label: 'Lies', async resolve() { return { nope: true } } },
|
||
{ id: 'demo.options.empty', label: 'Empty', async resolve() { return [] } },
|
||
])
|
||
}`))
|
||
|
||
const threw = await registries.resolveOptionSource('demo.options.throws')
|
||
assert.equal(threw.ok, false)
|
||
assert.match(threw.reason, /could not be read/)
|
||
|
||
const lied = await registries.resolveOptionSource('demo.options.lies')
|
||
assert.equal(lied.ok, false)
|
||
assert.match(lied.reason, /no option list/)
|
||
|
||
// An EMPTY list is not a failure. A source that legitimately has nothing to
|
||
// offer today — no landmarks configured yet — must not be reported as broken,
|
||
// because the two have different fixes.
|
||
const empty = await registries.resolveOptionSource('demo.options.empty')
|
||
assert.equal(empty.ok, true)
|
||
assert.deepEqual(empty.options, [])
|
||
|
||
const missing = await registries.resolveOptionSource('demo.options.gone')
|
||
assert.equal(missing.ok, false)
|
||
assert.match(missing.reason, /no module registers/)
|
||
})
|
||
|
||
// ── Uninstall is dormancy, never an error ──────────────────────────────────
|
||
|
||
test('an action whose module is gone goes dormant, and a step naming it fails terminal with the module named', async () => {
|
||
// §F and §L, verbatim: *"a step naming one fails terminal with the module named
|
||
// and the run degrades — never a silent skip"*. The scenario is real and
|
||
// ordinary: a module was uninstalled between the publish that pinned the
|
||
// version and the run that dispatches it.
|
||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
}`))
|
||
assert.equal(registries.isEventAction('demo.summon'), true)
|
||
|
||
// The uninstall: a fresh scan of a directory the module is no longer in.
|
||
fs.rmSync(path.join(tmpRoot, 'demo'), { recursive: true, force: true })
|
||
loadModule('other', 'module.exports = () => {}')
|
||
assert.equal(registries.isEventAction('demo.summon'), false)
|
||
|
||
const result = await dispatch.dispatchStep(step('demo.summon'), { run: RUN })
|
||
assert.equal(result.outcome, 'terminal')
|
||
assert.equal(result.dormant, true)
|
||
assert.match(result.error, /no module registers "demo\.summon"/)
|
||
})
|
||
|
||
// ── Phase 8: the ledger's two callables, from a module ─────────────────────
|
||
//
|
||
// `revert` was already required at registration for `reversible: 'ledger'` —
|
||
// Phase 1 put that check in. What Phase 8 added is a caller for it, and
|
||
// `reconcile` beside it. Both are proved here through the REAL loader for the
|
||
// same reason the four registrations are: a `revert` a test called directly is a
|
||
// `revert` core might still have no way to reach.
|
||
|
||
test('a module\'s revert is reached by the cleanup sweep, resources and key in hand', async () => {
|
||
const record = loadModule('demo', `
|
||
let seen = null
|
||
module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'demo.spawn',
|
||
label: 'Spawn',
|
||
risk: 'change',
|
||
reversible: 'ledger',
|
||
params: [],
|
||
async perform() { return { ok: true, resources: [{ kind: 'creature', ref: '0xA' }] } },
|
||
async revert(arg) { seen = arg; return { ok: true } },
|
||
async reconcile() { return { ok: true, inForce: [] } },
|
||
}])
|
||
api.registerEventOptionSources([
|
||
{ id: 'demo.options.seen', label: 'seen', async resolve() { return [{ value: JSON.stringify(seen), label: 'seen' }] } },
|
||
])
|
||
}
|
||
`)
|
||
assertRegistered(record)
|
||
|
||
const action = registries.eventAction('demo.spawn')
|
||
// Both callables survived the registration copy — which is not a given: that
|
||
// copy is explicit rather than a spread, precisely so nothing rides along, and
|
||
// a member added to the contract without being added to it is a member that
|
||
// silently does not exist.
|
||
assert.equal(typeof action.revert, 'function')
|
||
assert.equal(typeof action.reconcile, 'function')
|
||
assert.equal(action.owner, 'demo')
|
||
|
||
const answer = await action.revert({
|
||
runId: 3,
|
||
resources: [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }],
|
||
idempotencyKey: 'k-1',
|
||
})
|
||
assert.deepEqual(answer, { ok: true })
|
||
|
||
// Read back through the module's own option source rather than out of a
|
||
// closure this file holds: the point is that what core PASSED is what the
|
||
// module SAW, across the seam.
|
||
const seen = JSON.parse((await registries.resolveOptionSource('demo.options.seen')).options[0].value)
|
||
assert.equal(seen.runId, 3)
|
||
assert.equal(seen.idempotencyKey, 'k-1')
|
||
assert.deepEqual(seen.resources, [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }])
|
||
})
|
||
|
||
test('reconcile is optional, and a module without one still registers', () => {
|
||
// The asymmetry with `revert`, from the loader's side. A module that cannot say
|
||
// what the game still has is not broken — core keeps believing its own ledger,
|
||
// which is the behaviour before this phase — whereas one that creates something
|
||
// and cannot undo it has made a promise core has no way to keep.
|
||
const withNone = loadModule('quiet', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'quiet.spawn',
|
||
label: 'Spawn',
|
||
risk: 'change',
|
||
reversible: 'ledger',
|
||
params: [],
|
||
async perform() { return { ok: true } },
|
||
async revert() { return { ok: true } },
|
||
}])
|
||
}`)
|
||
assertRegistered(withNone)
|
||
assert.equal(registries.eventAction('quiet.spawn').reconcile, null)
|
||
|
||
const withoutRevert = loadModule('broken', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'broken.spawn',
|
||
label: 'Spawn',
|
||
risk: 'change',
|
||
reversible: 'ledger',
|
||
params: [],
|
||
async perform() { return { ok: true } },
|
||
}])
|
||
}`)
|
||
assert.equal(withoutRevert.state, 'startup_failed')
|
||
assert.match(withoutRevert.reason, /reversible: 'ledger' but has no revert\(\)/)
|
||
})
|
||
|
||
test('a module cannot claim core\'s reserved resource kind', async () => {
|
||
// `@step` is the placeholder's kind, and the placeholder is the row whose
|
||
// survival is the safety property: a module able to write one could make its
|
||
// own step look already accounted for. Refused at recording, and the STEP still
|
||
// succeeds — because it did.
|
||
const record = loadModule('sneaky', `module.exports = (ctx, api) => {
|
||
api.registerEventActions([{
|
||
id: 'sneaky.spawn',
|
||
label: 'Spawn',
|
||
risk: 'change',
|
||
reversible: 'ledger',
|
||
params: [],
|
||
async perform() { return { ok: true, resources: [{ kind: '@step', ref: 'anything' }] } },
|
||
async revert() { return { ok: true } },
|
||
}])
|
||
}`)
|
||
assertRegistered(record)
|
||
|
||
// eslint-disable-next-line global-require
|
||
const ledger = require('../src/events/ledger')
|
||
const result = await dispatch.dispatchStep(step('sneaky.spawn'), { run: RUN })
|
||
assert.equal(result.outcome, 'done')
|
||
const parsed = ledger.normalise(result.resources[0], 'sneaky.spawn')
|
||
assert.equal(parsed.ok, false)
|
||
assert.match(parsed.reason, /reserved kind/)
|
||
})
|
||
|
||
test('core registers the lease VERB and a module registers the lease', async () => {
|
||
// The seam working the way round it is meant to (Phase 8). A module ships the
|
||
// three callables; the verb an author puts in a step is `core.lease`, so the
|
||
// duration bound and the two-events-one-target conflict check live in one place
|
||
// rather than being re-implemented once per module and advisory everywhere.
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventLeases([{
|
||
id: 'demo.rate.gain',
|
||
label: 'Gain rate',
|
||
type: 'float',
|
||
min: 0.5,
|
||
max: 5,
|
||
maxDurationMs: 3600000,
|
||
async read() { return { ok: true, value: 1 } },
|
||
async apply() { return { ok: true } },
|
||
async restore() { return { ok: true } },
|
||
}])
|
||
}`)
|
||
assertRegistered(record)
|
||
registries.registerCore()
|
||
|
||
// The module registers no ACTION at all, and its lease is still reachable.
|
||
assert.equal(registries.eventAction('demo.lease'), null)
|
||
assert.equal(registries.eventAction('core.lease').reversible, 'override')
|
||
|
||
// And the dropdown behind `core.lease`'s first param is answered by what the
|
||
// module declared — resolved per request, so a module that booted later is
|
||
// still in the list.
|
||
const options = await registries.resolveOptionSource('core.options.leases')
|
||
assert.equal(options.ok, true)
|
||
assert.deepEqual(options.options, [{ value: 'demo.rate.gain', label: 'Gain rate', group: 'demo' }])
|
||
})
|
||
|
||
test('core refuses a lease held longer than the module allows', async () => {
|
||
// The bound is the MODULE's number and the enforcement is CORE's, which is the
|
||
// §F split stated as one assertion. `retry: false` because a duration that is
|
||
// too long will still be too long in sixty seconds: it is an authoring error,
|
||
// not an outage.
|
||
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||
api.registerEventLeases([{
|
||
id: 'demo.rate.gain',
|
||
label: 'Gain rate',
|
||
type: 'float',
|
||
min: 0.5,
|
||
max: 5,
|
||
maxDurationMs: 3600000,
|
||
async read() { return { ok: true, value: 1 } },
|
||
async apply() { return { ok: true } },
|
||
async restore() { return { ok: true } },
|
||
}])
|
||
}`)
|
||
assertRegistered(record)
|
||
registries.registerCore()
|
||
|
||
const tooLong = await dispatch.dispatchStep(
|
||
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 120 }),
|
||
{ run: RUN },
|
||
)
|
||
assert.equal(tooLong.outcome, 'terminal')
|
||
assert.match(tooLong.error, /at most 60 minutes, not 120/)
|
||
|
||
// The same for a value outside the declared range. Unlike a cap, a bad lease
|
||
// value is in force the moment it is applied, which is why min/max are required
|
||
// on the numeric types rather than advisory.
|
||
const tooBig = await dispatch.dispatchStep(
|
||
step('core.lease', { lease: 'demo.rate.gain', value: '9', minutes: 10 }),
|
||
{ run: RUN },
|
||
)
|
||
assert.equal(tooBig.outcome, 'terminal')
|
||
assert.match(tooBig.error, /accepts 0\.5 to 5/)
|
||
|
||
// And a lease nobody registers, which is the dormancy rule one registry along.
|
||
const missing = await dispatch.dispatchStep(
|
||
step('core.lease', { lease: 'demo.nope', value: '3', minutes: 10 }),
|
||
{ run: RUN },
|
||
)
|
||
assert.equal(missing.outcome, 'terminal')
|
||
assert.match(missing.error, /no module registers the lease "demo\.nope"/)
|
||
})
|
||
|
||
test('a dry run of core.lease checks everything and takes nothing', async () => {
|
||
// `verify: true` must change nothing and must answer honestly (§F). A verify
|
||
// that reserved the target would be a dry run that changed something — and it
|
||
// would then refuse the real run that followed it, which is the worst of both.
|
||
let applied = 0
|
||
const record = loadModule('demo', `
|
||
let applied = 0
|
||
module.exports = (ctx, api) => {
|
||
api.registerEventLeases([{
|
||
id: 'demo.rate.gain',
|
||
label: 'Gain rate',
|
||
type: 'float',
|
||
min: 0.5,
|
||
max: 5,
|
||
maxDurationMs: 3600000,
|
||
async read() { return { ok: true, value: 1 } },
|
||
async apply() { applied += 1; return { ok: true } },
|
||
async restore() { return { ok: true } },
|
||
}])
|
||
api.registerEventOptionSources([
|
||
{ id: 'demo.options.applied', label: 'applied', async resolve() { return [{ value: String(applied), label: 'n' }] } },
|
||
])
|
||
}
|
||
`)
|
||
assertRegistered(record)
|
||
registries.registerCore()
|
||
void applied
|
||
|
||
const ok = await dispatch.dispatchStep(
|
||
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10 }),
|
||
{ run: RUN, verify: true },
|
||
)
|
||
assert.equal(ok.outcome, 'done')
|
||
assert.equal((await registries.resolveOptionSource('demo.options.applied')).options[0].value, '0')
|
||
|
||
// A dry run that is still a real check: the bad duration is caught with
|
||
// `verify: true` as well, which is the whole value of the switchboard's
|
||
// "find out before you schedule it".
|
||
const bad = await dispatch.dispatchStep(
|
||
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 999 }),
|
||
{ run: RUN, verify: true },
|
||
)
|
||
assert.equal(bad.outcome, 'terminal')
|
||
})
|