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)
})

View File

@@ -0,0 +1,371 @@
// ── 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,
},
)
})

View File

@@ -52,6 +52,9 @@ const resourcesDb = require('../src/model/events/eventRunResources.db')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
// Rust PLAN_REDESIGNS §14.3.12: the editor's shape reads the trigger-start state.
// The same rule as every leg above, a fifth time.
const triggerStartsDb = require('../src/model/events/eventTriggerStarts.db')
const activity = require('../src/model/activity/activity.model')
const db = require('../src/utils/db')
@@ -69,6 +72,7 @@ for (const [name, mod] of [
['logDb', logDb],
['seriesDb', seriesDb],
['gatesDb', gatesDb],
['triggerStartsDb', triggerStartsDb],
['settingsDb', settingsDb],
['budgetDb', budgetDb],
['resourcesDb', resourcesDb],
@@ -96,6 +100,7 @@ function installStubs() {
log: [],
series: new Map(),
gates: [],
triggerStarts: new Map(),
settings: new Map(),
budget: new Map(),
resources: [],
@@ -295,6 +300,7 @@ function installStubs() {
// it merely made the file slow. Worth the comment: when the runner or a model
// gains a leg, every file that stubs the layer under it needs the stub.
gatesDb.listForRun = async (runId) => store.gates.filter((g) => g.run_id === runId)
triggerStartsDb.forDefinition = async (id) => store.triggerStarts.get(id) || null
logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {