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
372 lines
16 KiB
JavaScript
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,
|
|
},
|
|
)
|
|
})
|