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:
@@ -63,17 +63,70 @@ test('the catalog carries no callable', () => {
|
||||
assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
|
||||
})
|
||||
|
||||
test('core placeholders refuse rather than claiming success', async () => {
|
||||
// Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters
|
||||
// because `ok: true` on an action that did nothing is a recorded world change
|
||||
// that did not occur — the one wrong answer a stub can give.
|
||||
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()
|
||||
for (const id of ['core.announce', 'core.wait', 'core.cue']) {
|
||||
const answer = await registries.eventAction(id).perform({})
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, false)
|
||||
assert.match(answer.error, new RegExp(id.replace('.', '\\.')))
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user