Files
website/server/test/eventStarts.test.js
wtclaude c9e992588b
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 12m45s
feat(events): a trigger can start a run
Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's
published version may carry `starts`: a trigger, a condition and a
cooldown. A matching firing creates a run of it, now.

- spec: `starts` validated by the phase gate's own function (same
  grammar, same dormant rule; a dormant start refuses publish), a
  cooldown from 0m to 7d (30m default), at most ten, event.* refused.
- events/starts.js: the emit path's third subscriber, beside dispatch
  and the gate observer. An unverified version starts nothing; one run
  at a time per definition and the cooldown are one conditional UPDATE
  on the new event_trigger_starts table; the occurrence key makes one
  firing reported twice one run. Ignored firings are counted by why.
- event_runs.origin: the firing (trigger, label, time, subject, matched
  condition, declared variables), also on the run.created audit line.
- Editor: a "Started by the game" panel using the gate's condition
  builder, with the ignored-firings line. Run console: "Started because
  ... fired".
- No module contract change and no MODULE_API bump.

Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a
real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the
dev database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 01:51:55 -05:00

372 lines
16 KiB
JavaScript

// ── Starting a run from a trigger (Rust PLAN_REDESIGNS §14.3.12, D370) ─────
//
// Two halves. The SPEC half: a definition's `starts` is validated the way a
// phase gate is (one grammar, one dormant rule), with a cooldown, and core's own
// `event.*` triggers refused. The EMIT half: `events/starts.observe()` decides
// whether a firing starts a run, and this file proves what it decides against an
// in-memory stand-in whose claim keeps the SQL's rules. The SQL itself — one
// winner, the cooldown and the one-at-a-time check in one WHERE — is proved
// against a real server in `eventRunnerSql.test.js`.
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 starts = require('../src/events/starts')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const startsDb = require('../src/model/events/eventTriggerStarts.db')
const runsModel = require('../src/model/events/eventRuns.model')
const db = require('../src/utils/db')
after(() => db.close())
const TRIGGER = 'demo.bradley.destroyed'
beforeEach(() => {
registries._reset()
registries.registerCore()
const api = registries.stage('demo')
api.registerEventTriggers([
{
id: TRIGGER,
label: 'Bradley destroyed',
ceiling: 'everyone',
variables: [
{ name: 'server', type: 'string', required: true, example: 'main', description: 'The server.' },
{ name: 'grid', type: 'string', required: false, example: 'G8', description: 'Where.' },
{ name: 'kills', type: 'int', required: false, example: 1, description: 'How many.' },
],
},
])
registries.apply(api.staged)
})
afterEach(() => registries._reset())
const withStarts = (startsValue) => ({
schedule: { kind: 'manual' },
starts: startsValue,
phases: [{ key: 'main', label: 'Main', steps: [] }],
})
// ── The spec half ──────────────────────────────────────────────────────────
test('a start is a gate\'s trigger and condition plus a cooldown, normalised', () => {
const result = spec.validate(
withStarts([{ on: TRIGGER, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '120m' }]),
)
assert.equal(result.ok, true, JSON.stringify(result.errors))
assert.deepEqual(result.spec.starts, [
{ on: TRIGGER, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '2h', dormant: false },
])
})
test('the cooldown defaults to thirty minutes, and zero is a real choice', () => {
const a = spec.validate(withStarts([{ on: TRIGGER }]))
assert.equal(a.spec.starts[0].cooldown, '30m')
assert.equal(a.spec.starts[0].where, null)
const b = spec.validate(withStarts([{ on: TRIGGER, cooldown: 0 }]))
assert.equal(b.spec.starts[0].cooldown, '0m')
assert.equal(spec.parseCooldown('0m'), 0)
})
test('a cooldown past seven days, or not a duration, is refused by name', () => {
for (const cooldown of ['8d', '1h30m', 'soon', -5]) {
const result = spec.validate(withStarts([{ on: TRIGGER, cooldown }]))
assert.equal(result.ok, false, String(cooldown))
assert.match(result.errors[0], /^spec\.starts\[0\]\.cooldown:/)
}
assert.equal(spec.validate(withStarts([{ on: TRIGGER, cooldown: '7d' }])).ok, true)
})
test('core\'s own event.* triggers cannot start a run', () => {
const result = spec.validate(withStarts([{ on: 'event.run.started' }]))
assert.equal(result.ok, false)
assert.match(result.errors[0], /event system's own triggers/)
})
test('the condition is checked against the trigger\'s declaration, path and all', () => {
const result = spec.validate(withStarts([{ on: TRIGGER, where: { variable: 'sever', cmp: 'eq', value: 'main' } }]))
assert.equal(result.ok, false)
assert.ok(result.errors.some((e) => e.startsWith('spec.starts[0].where') && e.includes('sever')), result.errors.join('\n'))
})
test('a start on a trigger nobody registers saves dormant and refuses to publish', () => {
const result = spec.validate(withStarts([{ on: 'gone.trigger', where: { variable: 'x', cmp: 'eq', value: 1 } }]))
assert.equal(result.ok, true)
assert.equal(result.spec.starts[0].dormant, true)
assert.deepEqual(result.spec.starts[0].where, { variable: 'x', cmp: 'eq', value: 1 }, 'kept, not dropped')
assert.deepEqual(spec.publishable(result.spec), { ok: false, dormant: ['gone.trigger'] })
})
test('validate accepts its own output, and an empty list is not written', () => {
const first = spec.validate(withStarts([{ on: TRIGGER, cooldown: '45m' }]))
const second = spec.validate(first.spec)
assert.deepEqual(second.spec, first.spec)
assert.equal('starts' in spec.validate(withStarts([])).spec, false)
})
test('bad shapes: not a list, too many, unknown keys', () => {
assert.match(spec.validate(withStarts({ on: TRIGGER })).errors[0], /spec\.starts: expected an array/)
const many = Array.from({ length: spec.MAX_STARTS + 1 }, () => ({ on: TRIGGER }))
assert.match(spec.validate(withStarts(many)).errors[0], /at most/)
assert.match(spec.validate(withStarts([{ on: TRIGGER, count: 2 }])).errors[0], /unknown key\(s\) count/)
})
// ── The emit half ──────────────────────────────────────────────────────────
const T0 = new Date('2026-10-16T20:00:00Z')
let store
const originals = {
definitionsDb: { ...definitionsDb },
startsDb: { ...startsDb },
runsModel: { ...runsModel },
}
// A run counts as going while it is not terminal — the SQL's own reading, with
// "scheduled and due" folded in because a trigger-started run is due at once.
const GOING = ['scheduled', 'starting', 'running', 'paused', 'ending']
function installStubs({ definitions }) {
store = { definitions, runs: [], state: new Map(), creates: [], nextRun: 1 }
definitionsDb.findStartingOn = async (triggerId) =>
store.definitions.filter((d) => (d.version_spec.starts || []).some((s) => s.on === triggerId))
const stateOf = (id) => {
if (!store.state.has(id)) {
store.state.set(id, {
claimed_at: null,
last_started_at: null,
last_run_id: null,
ignored_running: 0,
ignored_cooldown: 0,
ignored_unverified: 0,
last_ignored_reason: null,
})
}
return store.state.get(id)
}
const going = (id) => store.runs.some((r) => r.definition_id === id && GOING.includes(r.status))
startsDb.forDefinition = async (id) => store.state.get(id) || null
startsDb.isGoing = async (id) => going(id)
startsDb.claim = async (id, { at, cooldownSeconds, now }) => {
const s = stateOf(id)
if (s.claimed_at && now - s.claimed_at < startsDb.CLAIM_STALE_MS) return false
if (s.last_started_at && s.last_started_at > new Date(at.getTime() - cooldownSeconds * 1000)) return false
if (going(id)) return false
s.claimed_at = now
return true
}
startsDb.started = async (id, { at, runId }) => {
Object.assign(stateOf(id), {
claimed_at: null,
last_started_at: at,
last_run_id: runId,
ignored_running: 0,
ignored_cooldown: 0,
ignored_unverified: 0,
})
}
startsDb.release = async (id) => {
stateOf(id).claimed_at = null
}
startsDb.ignore = async (id, reason) => {
const s = stateOf(id)
s[`ignored_${reason}`] += 1
s.last_ignored_reason = reason
}
runsModel.create = async (definitionId, opts, userId) => {
store.creates.push({ definitionId, opts, userId })
const def = store.definitions.find((d) => d.id === definitionId)
if (!def.version_verified_at) return { ok: false, code: 'unverified', errors: ['unverified'] }
const existing = store.runs.find(
(r) => r.definition_id === definitionId && r.scheduled_for.getTime() === opts.scheduledFor.getTime(),
)
if (existing) return { ok: true, created: false, run: existing }
const run = { id: store.nextRun++, definition_id: definitionId, status: 'scheduled', scheduled_for: opts.scheduledFor, origin: opts.origin }
store.runs.push(run)
return { ok: true, created: true, run }
}
}
afterEach(() => {
Object.assign(definitionsDb, originals.definitionsDb)
Object.assign(startsDb, originals.startsDb)
Object.assign(runsModel, originals.runsModel)
})
const definition = (over = {}) => ({
id: 7,
title: 'Launch Site Lockdown',
grace_seconds: 900,
version_verified_at: new Date('2026-10-01T00:00:00Z'),
version_spec: {
schedule: { kind: 'manual' },
starts: [{ on: TRIGGER, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '30m', dormant: false }],
phases: [{ key: 'main', label: 'Main', steps: [] }],
},
...over,
})
const firing = (at, data = { server: 'main', grid: 'G8' }, extra = {}) => ({
triggerId: TRIGGER,
occurredAt: at.toISOString(),
subject: 'bradley-1',
data,
...extra,
})
const plus = (ms) => new Date(T0.getTime() + ms)
test('a matching firing starts one run, now, with its origin', async () => {
installStubs({ definitions: [definition()] })
const summary = await starts.observe(firing(T0, { server: 'main', grid: 'G8', stray: 'x' }), { now: plus(2_000) })
assert.deepEqual(summary, { definitions: 1, started: 1, ignored: 0 })
assert.equal(store.runs.length, 1)
const [{ opts, userId }] = store.creates
assert.equal(userId, null, 'no user started it')
assert.equal(opts.source, 'trigger')
assert.equal(opts.scheduledFor.toISOString(), T0.toISOString(), 'the firing\'s time, not the observer\'s')
assert.deepEqual(opts.origin, {
kind: 'trigger',
triggerId: TRIGGER,
label: 'Bradley destroyed',
occurredAt: T0.toISOString(),
subject: 'bradley-1',
matched: 'server is "main"',
// Declared variables only: `stray` is not something the trigger declares.
variables: { server: 'main', grid: 'G8' },
})
assert.equal(store.state.get(7).last_run_id, 1)
})
test('a firing that does not match the condition starts nothing and counts nothing', async () => {
installStubs({ definitions: [definition()] })
const summary = await starts.observe(firing(T0, { server: 'staging' }), { now: plus(1_000) })
assert.deepEqual(summary, { definitions: 1, started: 0, ignored: 0 })
assert.equal(store.creates.length, 0)
assert.equal(store.state.has(7), false)
})
test('a second firing while the run is going starts nothing, and is counted as running', async () => {
installStubs({ definitions: [definition({ version_spec: { ...definition().version_spec, starts: [{ ...definition().version_spec.starts[0], cooldown: '0m' }] } })] })
await starts.observe(firing(T0), { now: plus(1_000) })
const summary = await starts.observe(firing(plus(30_000)), { now: plus(31_000) })
assert.deepEqual(summary, { definitions: 1, started: 0, ignored: 1 })
assert.equal(store.runs.length, 1)
assert.equal(store.state.get(7).ignored_running, 1)
})
test('inside the cooldown, a firing starts nothing and is counted; after it, a second run', async () => {
installStubs({ definitions: [definition()] })
await starts.observe(firing(T0), { now: plus(1_000) })
store.runs[0].status = 'completed'
await starts.observe(firing(plus(10 * 60_000)), { now: plus(10 * 60_000) })
assert.equal(store.runs.length, 1)
assert.equal(store.state.get(7).ignored_cooldown, 1)
const summary = await starts.observe(firing(plus(31 * 60_000)), { now: plus(31 * 60_000) })
assert.equal(summary.started, 1)
assert.equal(store.runs.length, 2)
assert.equal(store.state.get(7).ignored_cooldown, 0, 'a new start zeroes the counts')
})
test('one firing reported twice is one run, and the repeat is not counted', async () => {
installStubs({ definitions: [definition()] })
await starts.observe(firing(T0), { now: plus(1_000) })
const summary = await starts.observe(firing(T0), { now: plus(1_500) })
assert.equal(summary.started, 0)
assert.equal(summary.ignored, 0)
assert.equal(store.runs.length, 1)
assert.equal(store.state.get(7).ignored_running, 0)
})
test('an unverified version is not started by a trigger, and is counted', async () => {
installStubs({ definitions: [definition({ version_verified_at: null })] })
const summary = await starts.observe(firing(T0), { now: plus(1_000) })
assert.deepEqual(summary, { definitions: 1, started: 0, ignored: 1 })
assert.equal(store.creates.length, 0, 'not even tried')
assert.equal(store.state.get(7).ignored_unverified, 1)
})
test('a firing older than the grace window starts nothing', async () => {
installStubs({ definitions: [definition({ grace_seconds: 60 })] })
const summary = await starts.observe(firing(T0), { now: plus(5 * 60_000) })
assert.equal(summary.started, 0)
assert.equal(store.creates.length, 0)
})
test('a firing stamped in the future starts the run now, not later', async () => {
installStubs({ definitions: [definition()] })
await starts.observe(firing(plus(60 * 60_000)), { now: plus(1_000) })
assert.equal(store.creates[0].opts.scheduledFor.toISOString(), plus(1_000).toISOString())
})
test('core\'s event.* triggers are never looked up, and a dormant start never fires', async () => {
installStubs({ definitions: [] })
let asked = 0
definitionsDb.findStartingOn = async () => {
asked += 1
return []
}
await starts.observe({ triggerId: 'event.run.started', data: {} })
assert.equal(asked, 0)
installStubs({
definitions: [definition({ version_spec: { ...definition().version_spec, starts: [{ on: TRIGGER, where: null, cooldown: '30m', dormant: true }] } })],
})
const summary = await starts.observe(firing(T0), { now: plus(1_000) })
assert.equal(summary.started, 0)
})
test('two definitions on one trigger each decide for themselves', async () => {
const other = definition({ id: 8, title: 'Other', version_spec: { ...definition().version_spec, starts: [{ on: TRIGGER, where: null, cooldown: '30m', dormant: false }] } })
installStubs({ definitions: [definition(), other] })
const summary = await starts.observe(firing(T0, { server: 'staging' }), { now: plus(1_000) })
assert.deepEqual(summary, { definitions: 2, started: 1, ignored: 0 })
assert.equal(store.runs[0].definition_id, 8)
})
test('a create that throws gives the claim back and is not fatal to the others', async () => {
const other = definition({ id: 8 })
installStubs({ definitions: [definition(), other] })
const create = runsModel.create
runsModel.create = async (id, ...rest) => {
if (id === 7) throw new Error('boom')
return create(id, ...rest)
}
const summary = await starts.observe(firing(T0), { now: plus(1_000) })
assert.equal(summary.started, 1)
assert.equal(store.state.get(7).claimed_at, null)
})
test('an oversized payload keeps only the variables the condition named', () => {
const big = 'x'.repeat(starts.MAX_ORIGIN_BYTES)
const origin = starts.originOf(
{ triggerId: TRIGGER, data: { server: 'main', grid: big } },
registries.eventTrigger(TRIGGER),
{ where: { variable: 'server', cmp: 'eq', value: 'main' } },
T0,
)
assert.deepEqual(origin.variables, { server: 'main', _truncated: true })
})
test('the editor\'s state line, and its absence', () => {
assert.equal(starts.describeState(null), null)
assert.deepEqual(
starts.describeState({ last_started_at: T0, last_run_id: 4, ignored_running: 2, ignored_cooldown: 1, ignored_unverified: 0 }),
{
lastStartedAt: T0,
lastRunId: 4,
ignoredRunning: 2,
ignoredCooldown: 1,
ignoredUnverified: 0,
lastIgnoredAt: null,
lastIgnoredReason: null,
},
)
})