feat(events): a trigger can start a run
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

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
This commit is contained in:
2026-10-16 01:51:55 -05:00
parent eb773693d3
commit c9e992588b
18 changed files with 1352 additions and 20 deletions

View File

@@ -136,7 +136,8 @@ CREATE TABLE event_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version INT NOT NULL DEFAULT 1,
spec JSON NOT NULL
spec JSON NOT NULL,
verified_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_runs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -149,11 +150,16 @@ CREATE TABLE event_runs (
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
current_phase VARCHAR(64) NULL,
scheduled_for DATETIME NOT NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
concurrency_key VARCHAR(190) NULL,
params JSON NULL,
rehearsal TINYINT(1) NOT NULL DEFAULT 0,
started_at DATETIME NULL,
ended_at DATETIME NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
started_by INT NULL,
origin JSON NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -250,6 +256,18 @@ CREATE TABLE event_action_settings (
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_trigger_starts (
definition_id INT NOT NULL PRIMARY KEY,
claimed_at DATETIME NULL,
last_started_at DATETIME NULL,
last_run_id BIGINT NULL,
ignored_running INT NOT NULL DEFAULT 0,
ignored_cooldown INT NOT NULL DEFAULT 0,
ignored_unverified INT NOT NULL DEFAULT 0,
last_ignored_at DATETIME NULL,
last_ignored_reason VARCHAR(32) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`
// The statements under test, verbatim from `eventRuns.db.js` and
@@ -1745,3 +1763,131 @@ test('setCleanupStatus is guarded, which is what stops a late row re-opening a s
assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending', ['not_required']), false)
assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending'), true)
})
// ── A trigger can start a run (Rust PLAN_REDESIGNS §14.3.12, D370) ─────────
//
// `eventStarts.test.js` proves what `starts.observe()` DECIDES against a stand-in
// whose claim keeps the rules. These prove the rules are what the server does:
// the JSON lookup over the published version, and the claim — one winner, the
// cooldown and the one-at-a-time check all inside one WHERE.
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const triggerStartsDb = require('../src/model/events/eventTriggerStarts.db')
const startsOn = (on, extra = {}) => ({
schedule: { kind: 'manual' },
starts: [{ on, where: null, cooldown: '30m', dormant: false, ...extra }],
phases: [{ key: 'main', label: 'Main', steps: [] }],
})
test('findStartingOn reads the PUBLISHED version of ready definitions, and nothing else', async (t) => {
if (needDb(t)) return
const hit = await seedDefinition({ spec: startsOn('demo.bradley.destroyed') })
await seedDefinition({ state: 'draft', spec: startsOn('demo.bradley.destroyed') })
await seedDefinition({ state: 'archived', spec: startsOn('demo.bradley.destroyed') })
await seedDefinition({ spec: startsOn('demo.heli.downed') })
await seedDefinition({ spec: { schedule: { kind: 'manual' } } })
// Two starts, the trigger second: `$.starts[*].on` is every entry, not the first.
const second = await seedDefinition({
spec: { ...startsOn('demo.heli.downed'), starts: [{ on: 'demo.heli.downed' }, { on: 'demo.bradley.destroyed' }] },
})
await pool.query('UPDATE event_versions SET verified_at = ? WHERE id = ?', [T0, hit.versionId])
const found = await definitionsDb.findStartingOn('demo.bradley.destroyed')
assert.deepEqual(found.map((d) => Number(d.id)), [Number(hit.definitionId), Number(second.definitionId)])
assert.equal(found[0].version_spec.starts[0].on, 'demo.bradley.destroyed', 'the spec comes back parsed')
assert.ok(found[0].version_verified_at, 'and whether it may start unattended')
assert.equal(found[1].version_verified_at, null)
// A substring is not a trigger.
assert.equal((await definitionsDb.findStartingOn('demo.bradley')).length, 0)
})
test('two firings claiming at once: exactly one owns the start', async (t) => {
if (needDb(t)) return
const def = await seedDefinition({ spec: startsOn('demo.x', { cooldown: '0m' }) })
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
triggerStartsDb.claim(def.definitionId, { at: later(i * 1000), cooldownSeconds: 0, now: later(10_000) }),
),
)
assert.equal(results.filter(Boolean).length, 1)
})
test('the cooldown is measured from the last trigger-started run', async (t) => {
if (needDb(t)) return
const def = await seedDefinition({ spec: startsOn('demo.x') })
const id = def.definitionId
assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 1800, now: T0 }), true)
await triggerStartsDb.started(id, { at: T0, runId: 99 })
assert.equal(await triggerStartsDb.claim(id, { at: later(10 * 60_000), cooldownSeconds: 1800, now: later(10 * 60_000) }), false)
// Exactly at the boundary the cooldown has passed.
assert.equal(await triggerStartsDb.claim(id, { at: later(30 * 60_000), cooldownSeconds: 1800, now: later(30 * 60_000) }), true)
})
test('a going run blocks the claim; a future occurrence does not', async (t) => {
if (needDb(t)) return
const def = await seedDefinition({ spec: startsOn('demo.x', { cooldown: '0m' }) })
const id = def.definitionId
const run = await pool.query(
'INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for) VALUES (?, ?, ?, ?, ?)',
[id, def.versionId, '', 'scheduled', later(24 * 60 * 60_000)],
)
assert.equal(await triggerStartsDb.isGoing(id, T0), false, 'tomorrow\'s occurrence is not going')
assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), true)
await triggerStartsDb.release(id)
for (const [status, when] of [['running', T0], ['paused', T0], ['scheduled', later(-1000)]]) {
await pool.query('UPDATE event_runs SET status = ?, scheduled_for = ? WHERE id = ?', [status, when, run.insertId])
assert.equal(await triggerStartsDb.isGoing(id, T0), true, status)
assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), false, status)
}
await pool.query("UPDATE event_runs SET status = 'completed' WHERE id = ?", [run.insertId])
assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), true)
})
test('a claim held past a minute belongs to a dead process and can be taken', async (t) => {
if (needDb(t)) return
const def = await seedDefinition({ spec: startsOn('demo.x', { cooldown: '0m' }) })
const id = def.definitionId
assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), true)
assert.equal(await triggerStartsDb.claim(id, { at: later(30_000), cooldownSeconds: 0, now: later(30_000) }), false)
assert.equal(await triggerStartsDb.claim(id, { at: later(61_000), cooldownSeconds: 0, now: later(61_000) }), true)
})
test('ignore counts by reason, and a start zeroes the counts', async (t) => {
if (needDb(t)) return
const def = await seedDefinition({ spec: startsOn('demo.x') })
const id = def.definitionId
await triggerStartsDb.ignore(id, 'running', T0)
await triggerStartsDb.ignore(id, 'running', T0)
await triggerStartsDb.ignore(id, 'cooldown', later(1000))
let row = await triggerStartsDb.forDefinition(id)
assert.equal(row.ignored_running, 2)
assert.equal(row.ignored_cooldown, 1)
assert.equal(row.last_ignored_reason, 'cooldown')
await assert.rejects(() => triggerStartsDb.ignore(id, 'bogus', T0), /unknown ignore reason/)
await triggerStartsDb.started(id, { at: later(2000), runId: 5 })
row = await triggerStartsDb.forDefinition(id)
assert.equal(row.ignored_running, 0)
assert.equal(Number(row.last_run_id), 5)
assert.equal(row.claimed_at, null)
})
test('a run carries its origin through the insert and back', async (t) => {
if (needDb(t)) return
const def = await seedDefinition()
const origin = { kind: 'trigger', triggerId: 'demo.x', variables: { grid: 'G8' } }
const runId = await runsDb.materialise({
definition_id: def.definitionId,
version_id: def.versionId,
scope: '',
scheduled_for: T0,
concurrency_key: null,
params: null,
started_by: null,
origin,
})
assert.deepEqual((await runsDb.getById(runId)).origin, origin)
})