Files
website/server/test/eventSpec.test.js
wtclaude 9bc0bf5a3d
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s
feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 22:11:20 -05:00

480 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The event spec validator (EVENTS.md §C/§D, Phase 1) ────────────────────
//
// The boundary that decides whether a version may exist. Its interesting cases
// are all about time rather than shape:
//
// • a step's params are checked against the action's DECLARED params, and the
// action version it was authored against is captured at save
// • `on_failure` is defaulted from the risk class, because a `change` action
// that fell back to `skip` would advance a run over a half-changed world
// • an unregistered action is refused on a NEW step and KEPT on an existing
// one — the rule `engagementRules.model` established for a dormant trigger,
// for the same reason: an uninstall must not be destructive after the fact
// • a dormant step blocks a PUBLISH and never a SAVE
// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)`
// would silently collapse them into one at materialisation
// • a key a later phase owns (`announcements`) is REFUSED rather than
// preserved, so no corpus of unvalidated specs accumulates
// • a phase's `advance` gate (Phase 5) is checked at SAVE against the
// trigger's declaration with the offending variable named, and a gate on an
// unregistered trigger is dormant on exactly the rule a step's action is
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 spec = require('../src/events/spec')
const db = require('../src/utils/db')
after(() => db.close())
beforeEach(() => {
registries._reset()
registries.registerCore()
const api = registries.stage('demo')
api.registerEventActions([
{
id: 'demo.world.change',
label: 'Change the world',
risk: 'change',
reversible: 'none',
version: 4,
params: [
{ name: 'region', type: 'string', required: true, example: 'Yew' },
{ name: 'count', type: 'int', required: true, example: 12 },
{ name: 'hue', type: 'int', required: false, example: 1157 },
],
perform: async () => ({ ok: true }),
},
{
id: 'demo.world.wreck',
label: 'Wreck the world',
risk: 'irreversible',
reversible: 'none',
perform: async () => ({ ok: true }),
},
])
registries.apply(api.staged)
})
afterEach(() => registries._reset())
const oneStep = (step) => ({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [step] }],
})
test('the empty spec is valid, and is what a new draft carries', () => {
const result = spec.validate(spec.emptySpec())
assert.equal(result.ok, true)
assert.equal(result.spec.phases.length, 1)
assert.deepEqual(result.spec.schedule, { kind: 'manual' })
})
test('params are checked against the declaration and coerced', () => {
const result = spec.validate(
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 12 } }),
)
assert.equal(result.ok, true)
const [step] = result.spec.phases[0].steps
assert.deepEqual(step.params, { region: 'Yew', count: 12 })
// Captured from the declaration, not from the request: it is what lets a later
// bump warn in the editor instead of dispatching a mistyped parameter.
assert.equal(step.actionVersion, 4)
assert.equal(step.dormant, false)
})
test('a missing required param, a wrong type and an unknown param are all refused', () => {
const missing = spec.validate(oneStep({ actionId: 'demo.world.change', params: { region: 'Yew' } }))
assert.equal(missing.ok, false)
assert.match(missing.errors.join('\n'), /"count" is required/)
const wrong = spec.validate(
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 'twelve' } }),
)
assert.equal(wrong.ok, false)
assert.match(wrong.errors.join('\n'), /"count" expected an integer/)
// An unknown param is an ERROR, not a silent drop: an author who typed
// `regions` has written a step that would dispatch with the region missing,
// and dropping the key makes that look like it saved cleanly.
const typo = spec.validate(
oneStep({ actionId: 'demo.world.change', params: { regions: 'Yew', count: 1 } }),
)
assert.equal(typo.ok, false)
assert.match(typo.errors.join('\n'), /"regions" is not a param of demo\.world\.change/)
})
test('on_failure is defaulted from the risk class', () => {
const notify = spec.validate(
oneStep({ actionId: 'core.announce', params: { leg: 'discord', body: 'hi' } }),
)
assert.equal(notify.spec.phases[0].steps[0].onFailure, 'skip')
const change = spec.validate(
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 1 } }),
)
assert.equal(change.spec.phases[0].steps[0].onFailure, 'pause')
const irreversible = spec.validate(oneStep({ actionId: 'demo.world.wreck' }))
assert.equal(irreversible.spec.phases[0].steps[0].onFailure, 'abort_run')
// An author may still choose, within the closed set.
const chosen = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'skip' }))
assert.equal(chosen.spec.phases[0].steps[0].onFailure, 'skip')
const invented = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'shrug' }))
assert.equal(invented.ok, false)
assert.match(invented.errors.join('\n'), /onFailure: must be one of/)
})
test('a NEW step may not name an unregistered action', () => {
const result = spec.validate(oneStep({ actionId: 'gone.module.verb' }))
assert.equal(result.ok, false)
assert.match(result.errors.join('\n'), /no module registers "gone\.module\.verb"/)
})
test('an EXISTING step keeps its action when the module goes away, and is marked dormant', () => {
const saved = spec.validate(
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }),
).spec
// The module is uninstalled between one save and the next.
registries._reset()
registries.registerCore()
const again = spec.validate(saved, { knownActionIds: spec.actionIdsIn(saved) })
assert.equal(again.ok, true, again.errors && again.errors.join('\n'))
const [step] = again.spec.phases[0].steps
assert.equal(step.dormant, true)
// Params pass through untouched: the only thing that could validate them left
// with the module.
assert.deepEqual(step.params, { region: 'Yew', count: 3 })
// …and that is exactly what publish refuses.
const publishable = spec.publishable(again.spec)
assert.equal(publishable.ok, false)
assert.deepEqual(publishable.dormant, ['demo.world.change'])
})
test('validate accepts its own output — a saved spec is re-validated on every save', () => {
// The property the dormancy test above found the hard way: `validate` adds
// `actionVersion` and `dormant`, and a validator that then refused its own
// fields would make the SECOND save of any definition impossible, and publish
// — which re-validates before snapshotting — impossible full stop.
const once = spec.validate(
oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }),
)
const twice = spec.validate(once.spec)
assert.equal(twice.ok, true, twice.errors && twice.errors.join('\n'))
assert.deepEqual(twice.spec, once.spec)
})
test('two phases may not share a key', () => {
const result = spec.validate({
schedule: { kind: 'manual' },
phases: [
{ key: 'main', label: 'One', steps: [] },
{ key: 'main', label: 'Two', steps: [] },
],
})
assert.equal(result.ok, false)
assert.match(result.errors.join('\n'), /used by more than one phase/)
})
test('a key a later phase owns is refused, not silently preserved', () => {
const top = spec.validate({ schedule: { kind: 'manual' }, phases: [], announcements: [] })
assert.equal(top.ok, false)
assert.match(top.errors.join('\n'), /unknown key "announcements"/)
// `advance` was one of these until Phase 5 gave it a meaning; the refusal
// moved down a level rather than going away, and a key inside a gate that no
// shape declares is refused on the same argument.
const phase = spec.validate({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m', timeout: '2h' } }],
})
assert.equal(phase.ok, false)
assert.match(phase.errors.join('\n'), /unknown key\(s\) timeout for an "after" gate/)
})
// -- The advance gate (Phase 5) --------------------------------------------
//
// The phase's stated trap: a condition is checked at SAVE against the trigger's
// declaration, with the offending variable NAMED. A predicate that silently
// reads `undefined` is a phase that silently never advances, and the night you
// find out is the night of the event.
test('an `after` gate normalises its duration the way `days` is normalised', () => {
const ok = spec.validate({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '120m' } }],
})
assert.equal(ok.ok, true)
assert.deepEqual(ok.spec.phases[0].advance, { after: '2h' }, 'two spellings of one delay are one spec')
assert.equal(spec.parseAfter('2h'), 7200)
assert.equal(spec.formatAfter(5400), '90m', 'and a duration with no whole larger unit keeps the smaller one')
for (const bad of ['0s', '30', '1h30m', 'soon', '0.5h', `${31 * 86_400}s`]) {
const result = spec.validate({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: bad } }],
})
assert.equal(result.ok, false, `"${bad}" should not validate`)
assert.match(result.errors.join('\n'), /expected a duration like "30m"/)
}
})
test('an `on` gate is validated against the trigger declaration, and names the variable', () => {
const bad = spec.validate({
schedule: { kind: 'manual' },
phases: [
{
key: 'main',
label: 'Main',
steps: [],
advance: { on: 'news.post', where: { variable: 'reigon', cmp: 'eq', value: 'Yew' } },
},
],
})
assert.equal(bad.ok, false)
assert.match(bad.errors.join('\n'), /"reigon" is not a variable of "news.post"/)
assert.match(bad.errors.join('\n'), /spec\.phases\[0\]\.advance\.where/, 'and it says which phase')
// The type check is the grammar's, unchanged: `gt` on a string is refused at
// save rather than quietly answering false for ever.
const typed = spec.validate({
schedule: { kind: 'manual' },
phases: [
{ key: 'main', label: 'Main', steps: [], advance: { on: 'news.post', where: { variable: 'title', cmp: 'gt', value: 'x' } } },
],
})
assert.equal(typed.ok, false)
assert.match(typed.errors.join('\n'), /"gt" cannot be applied to a string/)
const ok = spec.validate({
schedule: { kind: 'manual' },
phases: [
{
key: 'main',
label: 'Main',
steps: [],
advance: { on: 'news.post', where: { variable: 'category', cmp: 'eq', value: 'Five on Friday' }, count: 3 },
},
],
})
assert.equal(ok.ok, true)
assert.deepEqual(ok.spec.phases[0].advance, {
on: 'news.post',
where: { variable: 'category', cmp: 'eq', value: 'Five on Friday' },
count: 3,
dormant: false,
})
})
test('a gate on an unregistered trigger is dormant: it saves, and it will not publish', () => {
const result = spec.validate({
schedule: { kind: 'manual' },
phases: [
{ key: 'main', label: 'Main', steps: [], advance: { on: 'gone.trigger', where: { variable: 'x', cmp: 'eq', value: 1 } } },
],
})
assert.equal(result.ok, true, 'uninstalling a module must not be destructive to an authors work')
assert.equal(result.spec.phases[0].advance.dormant, true)
assert.deepEqual(
result.spec.phases[0].advance.where,
{ variable: 'x', cmp: 'eq', value: 1 },
'and the predicate is kept, not deleted for want of a declaration to check it against',
)
const pub = spec.publishable(result.spec)
assert.equal(pub.ok, false)
assert.deepEqual(pub.dormant, ['gone.trigger'], 'the same list a dormant ACTION lands in, and the same message')
})
test('a gate names exactly one shape, and its count is bounded', () => {
const both = spec.validate({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '1h', on: 'news.post' } }],
})
assert.equal(both.ok, false)
assert.match(both.errors.join('\n'), /exactly one of "after" or "on"/)
const neither = spec.validate({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [], advance: {} }],
})
assert.equal(neither.ok, false)
for (const count of [0, -1, 1.5, spec.MAX_ADVANCE_COUNT + 1]) {
const result = spec.validate({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [], advance: { on: 'news.post', count } }],
})
assert.equal(result.ok, false, `count ${count} should not validate`)
}
})
test('validate accepts its own output — the walk found this one', () => {
// A saved spec is re-validated on every later save and AGAIN AT PUBLISH.
// `validate` normalises a gate with `dormant`, and the first draft refused
// that key on the way back in: the definition saved, and then publishing it
// answered 400 over a field the validator itself had written. The rule was
// already on the page for a step's `actionVersion` and `dormant`; the gate
// just had to follow it. `validate(validate(x)) === validate(x)`.
const once = spec.validate({
schedule: { kind: 'manual' },
phases: [
{ key: 'a', label: 'A', steps: [], advance: { on: 'news.post', where: { variable: 'title', cmp: 'present' }, count: 2 } },
{ key: 'b', label: 'B', steps: [], advance: { after: '15m' } },
],
})
assert.equal(once.ok, true)
const twice = spec.validate(once.spec)
assert.equal(twice.ok, true, twice.errors?.join('; '))
assert.deepEqual(twice.spec, once.spec)
// And `dormant` is RECOMPUTED, never trusted: a spec claiming a dead trigger
// is fine must not publish just because it says so.
const lying = spec.validate({
schedule: { kind: 'manual' },
phases: [{ key: 'a', label: 'A', steps: [], advance: { on: 'gone.trigger', count: 1, dormant: false } }],
})
assert.equal(lying.spec.phases[0].advance.dormant, true)
})
test('a phase with no gate carries no `advance` key at all', () => {
const result = spec.validate({ schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] })
assert.equal(result.ok, true)
assert.equal('advance' in result.spec.phases[0], false, 'so one phase gaining a gate is not a diff on every phase')
})
// ── The schedule shapes (Phase 4) ────────────────────────────────────
//
// Every check here is on SHAPE. What the shapes MEAN — the zone arithmetic, the
// DST rules — is `eventRecurrence.test.js`. The split is deliberate: this file
// answers "may this be saved", that one answers "when does it happen", and the
// second question is only worth asking of something that passed the first.
const withSchedule = (schedule) =>
spec.validate({ schedule, phases: [{ key: 'main', label: 'Main', steps: [] }] })
test('the four closed shapes are accepted and normalised', () => {
assert.deepEqual(withSchedule({ kind: 'manual' }).spec.schedule, { kind: 'manual' })
assert.deepEqual(withSchedule({ kind: 'once', at: '2026-10-31T20:00' }).spec.schedule, {
kind: 'once',
at: '2026-10-31T20:00',
})
assert.deepEqual(withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00' }).spec.schedule, {
kind: 'weekly',
days: ['friday'],
time: '20:00',
})
assert.deepEqual(
withSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }).spec.schedule,
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
)
})
test('the validator accepts its own output for every shape', () => {
// Phase 1's rule, and it is a rule about the SECOND save of any definition
// rather than about a round trip for its own sake: `validate` normalises, and
// publish re-validates what a save wrote. A normaliser that refuses what it
// emits makes a published definition uneditable.
for (const schedule of [
{ kind: 'manual' },
{ kind: 'once', at: '2026-10-31T20:00' },
{ kind: 'weekly', days: ['friday', 'monday'], time: '20:00' },
{ kind: 'monthly', nth: 4, weekday: 'friday', time: '19:30' },
]) {
const first = withSchedule(schedule)
assert.equal(first.ok, true, JSON.stringify(schedule))
const second = withSchedule(first.spec.schedule)
assert.equal(second.ok, true, JSON.stringify(first.spec.schedule))
assert.deepEqual(second.spec.schedule, first.spec.schedule)
}
})
test('weekly days are normalised into week order and deduped', () => {
// Not tidiness. The spec is snapshotted into a version and diffed, so two
// orderings of the same schedule would show as an edit nobody made.
const result = withSchedule({ kind: 'weekly', days: ['Friday', 'monday', 'FRIDAY'], time: '20:00' })
assert.deepEqual(result.spec.schedule.days, ['monday', 'friday'])
})
test('a shape may not carry another shape keys', () => {
const result = withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00', at: '2026-01-01T00:00' })
assert.equal(result.ok, false)
assert.match(result.errors.join('\n'), /unknown key\(s\) at for kind "weekly"/)
})
test('an unknown kind is refused, and the message names the four', () => {
const result = withSchedule({ kind: 'daily', time: '20:00' })
assert.equal(result.ok, false)
assert.match(result.errors.join('\n'), /manual, once, weekly, monthly/)
})
test('a date that is not a real day is refused', () => {
// The regex admits 2026-02-30 quite happily. A schedule that parses and then
// resolves to some other day is worse than one that is refused.
const result = withSchedule({ kind: 'once', at: '2026-02-30T20:00' })
assert.equal(result.ok, false)
assert.match(result.errors.join('\n'), /is not a real date/)
})
test('every malformed schedule field is named, not merely rejected', () => {
assert.match(withSchedule({ kind: 'once', at: 'soon' }).errors.join('\n'), /YYYY-MM-DDTHH:MM/)
assert.match(withSchedule({ kind: 'weekly', days: [], time: '20:00' }).errors.join('\n'), /non-empty array/)
assert.match(withSchedule({ kind: 'weekly', days: ['froday'], time: '20:00' }).errors.join('\n'), /unknown weekday/)
assert.match(withSchedule({ kind: 'weekly', days: ['friday'], time: '25:00' }).errors.join('\n'), /24-hour time/)
assert.match(
withSchedule({ kind: 'monthly', nth: 5, weekday: 'friday', time: '19:30' }).errors.join('\n'),
/1, 2, 3, 4 or -1/,
)
assert.match(
withSchedule({ kind: 'monthly', nth: 1, weekday: 'froday', time: '19:30' }).errors.join('\n'),
/expected one of sunday/,
)
})
test('a refused schedule leaves a manual one behind rather than half a recurrence', () => {
// `validate` collects every error and carries on, so the spec object exists
// even when the answer is no. A caller reading `schedule.days` of it must not
// find a partially built weekly.
const result = spec.validate({
schedule: { kind: 'weekly', days: ['froday'], time: '20:00' },
phases: [{ key: 'BAD KEY', label: '', steps: [] }],
})
assert.equal(result.ok, false)
assert.ok(result.errors.length > 1)
})
test('every problem is reported, not just the first', () => {
const result = spec.validate({
schedule: { kind: 'manual' },
phases: [
{ key: 'BAD KEY', label: '', steps: [{ actionId: 'demo.world.change', params: {} }] },
],
})
assert.equal(result.ok, false)
const joined = result.errors.join('\n')
assert.match(joined, /bad phase key/)
assert.match(joined, /a phase needs a label/)
assert.match(joined, /"region" is required/)
assert.match(joined, /"count" is required/)
})
test('the size bounds hold', () => {
const many = {
schedule: { kind: 'manual' },
phases: Array.from({ length: spec.MAX_PHASES + 1 }, (_, i) => ({
key: `p${i}`,
label: `P${i}`,
steps: [],
})),
}
const result = spec.validate(many)
assert.equal(result.ok, false)
assert.match(result.errors.join('\n'), new RegExp(`at most ${spec.MAX_PHASES} phases`))
})