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
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
371
server/test/eventStarts.test.js
Normal file
371
server/test/eventStarts.test.js
Normal 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,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user