Merge pull request 'feat(events): a trigger can start a run (Rust §14.3 stage 9, D370)' (#216) from feat/event-trigger-starts into main
Reviewed-on: #216 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -221,6 +221,89 @@ export function whereFormFrom(where) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Starting from a trigger (Rust PLAN_REDESIGNS §14.3.12, D370) ───────────
|
||||
//
|
||||
// A start is a phase gate's trigger and condition plus a cooldown, so the form
|
||||
// holds it in the gate's own fields — and the same `WhereBuilder` draws it. The
|
||||
// server (`events/spec.js`) decides; this only carries the fields.
|
||||
|
||||
export const DEFAULT_COOLDOWN = '30m'
|
||||
|
||||
// Core's own lifecycle triggers cannot start a run; the editor does not offer
|
||||
// them, and the server refuses them if posted anyway.
|
||||
export const isStartableTrigger = (trigger) => !String(trigger?.id || '').startsWith('event.')
|
||||
|
||||
export function blankStart() {
|
||||
return { on: '', cooldown: DEFAULT_COOLDOWN, dormant: false, ...blankWhere() }
|
||||
}
|
||||
|
||||
/** The stored `starts`, as the form's rows. */
|
||||
export function startsFormFrom(starts) {
|
||||
return (starts || []).map((s) => ({
|
||||
...blankStart(),
|
||||
on: s.on || '',
|
||||
cooldown: s.cooldown ?? DEFAULT_COOLDOWN,
|
||||
dormant: Boolean(s.dormant),
|
||||
...whereFormFrom(s.where),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's starts as the spec's list. A row with no trigger chosen is dropped
|
||||
* rather than posted — it is a half-added row, not a start the author wrote.
|
||||
*/
|
||||
export function startsPayload(rows, errors, triggersById = new Map()) {
|
||||
const out = []
|
||||
;(rows || []).forEach((row, i) => {
|
||||
if (!row.on) return
|
||||
const gate = advancePayload(
|
||||
{ ...row, kind: 'on' },
|
||||
`Start ${i + 1}`,
|
||||
errors,
|
||||
triggersById.get(row.on)?.variables || [],
|
||||
)
|
||||
const entry = { on: row.on, cooldown: String(row.cooldown ?? '').trim() || DEFAULT_COOLDOWN }
|
||||
if (gate?.where) entry.where = gate.where
|
||||
out.push(entry)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run exists, when a game firing started it — the console's sentence.
|
||||
* Null for a run started by hand or by the schedule.
|
||||
*/
|
||||
export function describeOrigin(origin) {
|
||||
if (!origin || origin.kind !== 'trigger') return null
|
||||
const vars = Object.entries(origin.variables || {})
|
||||
.filter(([k]) => k !== '_truncated')
|
||||
.map(([k, v]) => `${k} ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
const label = origin.label || origin.triggerId
|
||||
return [
|
||||
`Started because “${label}” fired`,
|
||||
origin.matched ? ` (matching ${origin.matched})` : '',
|
||||
vars.length ? `: ${vars.join(' · ')}` : '',
|
||||
].join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's line about what its trigger starts have done — or null when a
|
||||
* trigger has never tried. "2 firings ignored while running, 1 in its cooldown".
|
||||
*/
|
||||
export function describeTriggerStarts(state) {
|
||||
if (!state) return null
|
||||
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
||||
const ignored = [
|
||||
state.ignoredRunning ? `${plural(state.ignoredRunning, 'firing')} ignored while a run was going` : null,
|
||||
state.ignoredCooldown ? `${plural(state.ignoredCooldown, 'firing')} ignored in the cooldown` : null,
|
||||
state.ignoredUnverified
|
||||
? `${plural(state.ignoredUnverified, 'firing')} ignored because the published version is not verified`
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
const last = state.lastRunId ? `Last started run #${state.lastRunId}` : 'No run started by a trigger yet'
|
||||
return ignored.length ? `${last}; since then ${ignored.join(', ')}.` : `${last}.`
|
||||
}
|
||||
|
||||
/** The editor's working state, from what `GET /admin/events/:id` returned. */
|
||||
export function formFromDefinition(event) {
|
||||
const spec = event?.spec || {}
|
||||
@@ -243,6 +326,7 @@ export function formFromDefinition(event) {
|
||||
// `false`, and `||` would quietly re-list it on the next save.
|
||||
listed: event?.listed ?? true,
|
||||
...scheduleFormFrom(spec.schedule),
|
||||
starts: startsFormFrom(spec.starts),
|
||||
phases: (spec.phases || []).map((p) => ({
|
||||
key: p.key || '',
|
||||
label: p.label || '',
|
||||
@@ -347,6 +431,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||||
}
|
||||
})
|
||||
|
||||
const starts = startsPayload(form.starts, errors, triggersById)
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
return {
|
||||
@@ -363,7 +449,10 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||||
timezone: form.timezone,
|
||||
listed: Boolean(form.listed),
|
||||
listed: Boolean(form.listed),
|
||||
spec: { schedule: scheduleFromForm(form), phases },
|
||||
// `starts` only when there is one, as the server stores it (D370).
|
||||
spec: starts.length
|
||||
? { schedule: scheduleFromForm(form), starts, phases }
|
||||
: { schedule: scheduleFromForm(form), phases },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1012,7 +1101,9 @@ export function describeLogLine(line) {
|
||||
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
|
||||
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
|
||||
case 'run.created':
|
||||
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
|
||||
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}${
|
||||
d.source === 'trigger' && d.origin ? ` — ${describeOrigin(d.origin)}` : ''
|
||||
}`
|
||||
case 'phase.gate':
|
||||
return d.kind === 'after'
|
||||
? `${line.phase} advances ${d.after} after it started`
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
missingParams,
|
||||
guardedAdvance,
|
||||
shortfallSentence,
|
||||
blankStart,
|
||||
isStartableTrigger,
|
||||
describeTriggerStarts,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js'
|
||||
|
||||
@@ -841,7 +844,7 @@ function TemplatePicker({ templates, sources, loadSource, onDraft }) {
|
||||
* into `A and B and C` changes which firings release the phase, and an author
|
||||
* would have no way to know the save had done it.
|
||||
*/
|
||||
function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
|
||||
function WhereBuilder({ advance, trigger, operators, disabled, onChange, emptyText }) {
|
||||
const variables = trigger?.variables || []
|
||||
const rows = advance.whereRows || []
|
||||
|
||||
@@ -873,8 +876,7 @@ function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
|
||||
<span className="field-label">Only when</span>
|
||||
{rows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
|
||||
Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for
|
||||
firings that match.
|
||||
{emptyText || 'Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for firings that match.'}
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 1 && (
|
||||
@@ -1897,6 +1899,85 @@ export default function EventEditor() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Started by the game (Rust PLAN_REDESIGNS §14.3.12, D370) ──
|
||||
A start is a phase gate's trigger and condition plus a cooldown, so it
|
||||
is drawn with the gate's own builder. Beside the schedule rather than
|
||||
inside it: a definition may have either, or both. */}
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Started by the game</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
|
||||
Start a run when something happens in the game — <em>when a boss appears on a chosen server</em>.
|
||||
One run at a time: a firing while a run is going starts nothing, and nor does one inside the
|
||||
cooldown after the last run a trigger started. Like a schedule, it only starts a published
|
||||
version that has passed its dry run.
|
||||
</p>
|
||||
|
||||
{(form.starts || []).map((start, si) => {
|
||||
const setStart = (patch) =>
|
||||
set({ starts: form.starts.map((s, i) => (i === si ? { ...s, ...patch } : s)) })
|
||||
return (
|
||||
<div key={si} style={{ borderTop: si ? '1px solid var(--rule, #2a2f3a)' : 'none', paddingTop: si ? 10 : 0, marginTop: si ? 10 : 0 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 260px' }}>
|
||||
<span className="field-label">When</span>
|
||||
{/* Changing the trigger clears the condition, as a gate's does. */}
|
||||
<select className="input" value={start.on} disabled={archived}
|
||||
onChange={(e) => setStart({ on: e.target.value, dormant: false, ...blankWhere() })}>
|
||||
<option value="">Choose a trigger…</option>
|
||||
{start.on && !triggerById.has(start.on) && (
|
||||
<option value={start.on}>{start.on} — no module registers it</option>
|
||||
)}
|
||||
{triggers.filter(isStartableTrigger).map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label} — {t.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ flex: '0 1 140px' }}>
|
||||
<span className="field-label">Cooldown</span>
|
||||
<input className="input" value={start.cooldown} disabled={archived} placeholder="30m"
|
||||
onChange={(e) => setStart({ cooldown: e.target.value })} />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={archived}
|
||||
onClick={() => set({ starts: form.starts.filter((_, i) => i !== si) })}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{start.on && !triggerById.has(start.on) && (
|
||||
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '6px 0 0' }}>
|
||||
No installed module registers <code>{start.on}</code>. The start is kept, and the
|
||||
definition cannot be published until the module is back or the start is removed.
|
||||
</p>
|
||||
)}
|
||||
{start.on && (
|
||||
<WhereBuilder
|
||||
advance={start}
|
||||
trigger={triggerById.get(start.on)}
|
||||
operators={catalog?.operators || []}
|
||||
disabled={archived}
|
||||
emptyText="Every firing of this trigger starts a run. Add a clause to narrow it — only firings that match then start one."
|
||||
onChange={(patch) => setStart(patch)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', marginTop: 10 }}
|
||||
disabled={archived || (form.starts || []).length >= 10}
|
||||
onClick={() => set({ starts: [...(form.starts || []), blankStart()] })}>
|
||||
Add a trigger
|
||||
</button>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
||||
A cooldown is a whole number of <code>s</code>, <code>m</code>, <code>h</code> or <code>d</code>,
|
||||
from <code>0m</code> to <code>7d</code>. Zero leaves one run at a time as the only limit.
|
||||
</p>
|
||||
{describeTriggerStarts(event?.triggerStarts) && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||||
{describeTriggerStarts(event.triggerStarts)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── The live cap meter (Phase 13) ──
|
||||
Above the timeline rather than under it, because what a plan draws is a
|
||||
fact about the whole plan and an author scrolling twelve steps to find
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
stepControlsFor,
|
||||
describeLogLine,
|
||||
describeParams,
|
||||
describeOrigin,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
|
||||
@@ -294,6 +295,13 @@ export default function EventRun() {
|
||||
{run.rehearsal ? ' · rehearsal' : ''}
|
||||
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
|
||||
</p>
|
||||
{/* Why it exists, when a game firing started it (D370). */}
|
||||
{describeOrigin(run.origin) && (
|
||||
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
||||
{describeOrigin(run.origin)}
|
||||
<span className="dim"> · {when(run.origin.occurredAt)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
|
||||
|
||||
110
client/test/eventStarts.test.js
Normal file
110
client/test/eventStarts.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// A definition's trigger starts in the editor, and a run's origin on the
|
||||
// console (Rust PLAN_REDESIGNS §14.3.12, D370). The server decides; these prove
|
||||
// the form carries what it is given and the sentences read right.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
formFromDefinition,
|
||||
payloadFromForm,
|
||||
blankStart,
|
||||
startsFormFrom,
|
||||
startsPayload,
|
||||
isStartableTrigger,
|
||||
describeOrigin,
|
||||
describeTriggerStarts,
|
||||
describeLogLine,
|
||||
} from '../src/lib/eventAuthoring.js'
|
||||
|
||||
const TRIGGER = {
|
||||
id: 'rust.world.bradley_destroyed',
|
||||
label: 'Bradley destroyed',
|
||||
variables: [
|
||||
{ name: 'server', type: 'string' },
|
||||
{ name: 'kills', type: 'int' },
|
||||
],
|
||||
}
|
||||
const triggersById = new Map([[TRIGGER.id, TRIGGER]])
|
||||
|
||||
const definition = (starts) => ({
|
||||
title: 'Launch Site Lockdown',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
...(starts ? { starts } : {}),
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
},
|
||||
})
|
||||
|
||||
test('a stored start round-trips through the form unchanged', () => {
|
||||
const starts = [{ on: TRIGGER.id, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '2h' }]
|
||||
const form = formFromDefinition(definition(starts))
|
||||
assert.equal(form.starts.length, 1)
|
||||
assert.equal(form.starts[0].whereRows.length, 1)
|
||||
const built = payloadFromForm(form, { triggersById })
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.starts, starts)
|
||||
})
|
||||
|
||||
test('no starts is no key, as the server stores it', () => {
|
||||
const built = payloadFromForm(formFromDefinition(definition()), { triggersById })
|
||||
assert.equal('starts' in built.payload.spec, false)
|
||||
assert.deepEqual(formFromDefinition(definition()).starts, [])
|
||||
})
|
||||
|
||||
test('a half-added row is dropped, a blank cooldown is the default, a literal takes the declared type', () => {
|
||||
const rows = [
|
||||
blankStart(),
|
||||
{ ...blankStart(), on: TRIGGER.id, cooldown: ' ', whereRows: [{ variable: 'kills', cmp: 'gte', value: '2' }] },
|
||||
]
|
||||
const errors = []
|
||||
const out = startsPayload(rows, errors, triggersById)
|
||||
assert.deepEqual(errors, [])
|
||||
assert.deepEqual(out, [{ on: TRIGGER.id, cooldown: '30m', where: { variable: 'kills', cmp: 'gte', value: 2 } }])
|
||||
})
|
||||
|
||||
test('a nested condition the builder cannot draw is posted back verbatim', () => {
|
||||
const where = { op: 'and', nodes: [{ variable: 'server', cmp: 'eq', value: 'main' }, { op: 'or', nodes: [{ variable: 'kills', cmp: 'gt', value: 1 }, { variable: 'kills', cmp: 'lt', value: 0 }] }] }
|
||||
const [row] = startsFormFrom([{ on: TRIGGER.id, where, cooldown: '0m' }])
|
||||
assert.equal(row.whereEditable, false)
|
||||
assert.deepEqual(startsPayload([row], [], triggersById), [{ on: TRIGGER.id, cooldown: '0m', where }])
|
||||
})
|
||||
|
||||
test('core\'s own lifecycle triggers are not offered', () => {
|
||||
assert.equal(isStartableTrigger({ id: 'event.run.started' }), false)
|
||||
assert.equal(isStartableTrigger(TRIGGER), true)
|
||||
})
|
||||
|
||||
test('the origin sentence', () => {
|
||||
assert.equal(describeOrigin(null), null)
|
||||
assert.equal(
|
||||
describeOrigin({
|
||||
kind: 'trigger',
|
||||
triggerId: TRIGGER.id,
|
||||
label: 'Bradley destroyed',
|
||||
matched: 'server is "main"',
|
||||
variables: { server: 'main', grid: 'G8', _truncated: true },
|
||||
}),
|
||||
'Started because “Bradley destroyed” fired (matching server is "main"): server main · grid G8',
|
||||
)
|
||||
assert.equal(describeOrigin({ kind: 'trigger', triggerId: 'x.y', variables: {} }), 'Started because “x.y” fired')
|
||||
})
|
||||
|
||||
test('the editor\'s ignored-firings line', () => {
|
||||
assert.equal(describeTriggerStarts(null), null)
|
||||
assert.equal(
|
||||
describeTriggerStarts({ lastRunId: 12, ignoredRunning: 2, ignoredCooldown: 1, ignoredUnverified: 0 }),
|
||||
'Last started run #12; since then 2 firings ignored while a run was going, 1 firing ignored in the cooldown.',
|
||||
)
|
||||
assert.equal(
|
||||
describeTriggerStarts({ lastRunId: null, ignoredRunning: 0, ignoredCooldown: 0, ignoredUnverified: 1 }),
|
||||
'No run started by a trigger yet; since then 1 firing ignored because the published version is not verified.',
|
||||
)
|
||||
assert.equal(describeTriggerStarts({ lastRunId: 3 }), 'Last started run #3.')
|
||||
})
|
||||
|
||||
test('the audit line names a trigger start', () => {
|
||||
const line = describeLogLine({
|
||||
kind: 'run.created',
|
||||
detail: { version: 2, source: 'trigger', origin: { kind: 'trigger', label: 'Bradley destroyed', variables: { grid: 'G8' } } },
|
||||
})
|
||||
assert.equal(line, 'Occurrence created from version 2 — Started because “Bradley destroyed” fired: grid G8')
|
||||
})
|
||||
@@ -2704,3 +2704,43 @@ ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NU
|
||||
-- that no longer resolves answers that honestly.
|
||||
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
|
||||
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
|
||||
|
||||
-- ── A trigger can start a run (Rust PLAN_REDESIGNS §14.3.12, D370) ─────────
|
||||
|
||||
-- Why a run exists, when a game firing started it: the trigger, when it fired,
|
||||
-- its subject and its variables, so the console can say "started because a
|
||||
-- champion boss is up at Abyss" and the audit can say what. NULL for a run an
|
||||
-- admin or the schedule started, which `run.created`'s `source` already says.
|
||||
--
|
||||
-- A column rather than only a log line because the run log is pruned and the
|
||||
-- run is not, and "why did this run happen" outlives "what did each step do".
|
||||
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS origin JSON NULL;
|
||||
|
||||
-- One row per definition that a trigger has tried to start: the cooldown's
|
||||
-- clock, the claim that makes "one run at a time" true, and the count of
|
||||
-- firings it ignored, which the editor shows ("2 firings ignored while
|
||||
-- running").
|
||||
--
|
||||
-- **The claim is a conditional UPDATE on this row**, the event_run_budget
|
||||
-- argument once more: two firings arriving together each try it, and the
|
||||
-- cooldown, the one-at-a-time check and a stale-claim guard are all in its
|
||||
-- WHERE, so exactly one of them owns the start. `claimed_at` is held only
|
||||
-- between the claim and the run row existing — a minute is a crashed process,
|
||||
-- not a slow insert.
|
||||
--
|
||||
-- The counts are since the LAST trigger-started run, and a new start zeroes
|
||||
-- them: what an operator asks is "is it ignoring things right now", and a
|
||||
-- lifetime total cannot answer that.
|
||||
CREATE TABLE IF NOT EXISTS event_trigger_starts (
|
||||
definition_id INT NOT NULL PRIMARY KEY,
|
||||
claimed_at DATETIME NULL,
|
||||
last_started_at DATETIME NULL, -- the firing's time, which is the run's scheduled_for
|
||||
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,
|
||||
CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
// one that decides. A spec arriving by any other route (a restore, a fixture, a
|
||||
// module shipping a definition as content) gets the same answer.
|
||||
//
|
||||
// **What this file knows, and what it deliberately refuses.** Two top-level keys
|
||||
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
|
||||
// **What this file knows, and what it deliberately refuses.** Three top-level
|
||||
// keys exist today: `schedule`, `starts` and `phases`. Unknown top-level keys are REFUSED rather
|
||||
// than preserved: a spec that silently carries `announcements` today is a spec
|
||||
// whose author believes announcements work, and the later phase that gives the
|
||||
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
|
||||
// `advance`, Phase 10 adds `announcements`.
|
||||
// `advance`, Phase 10 adds `announcements`, and Rust PLAN_REDESIGNS §14.3.12
|
||||
// added `starts`, the triggers that begin a run (D370).
|
||||
//
|
||||
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
||||
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
|
||||
@@ -76,6 +77,24 @@ const MAX_AFTER_SECONDS = 30 * 86_400
|
||||
// never advances written as one that eventually does.
|
||||
const MAX_ADVANCE_COUNT = 1000
|
||||
|
||||
// A definition's `starts` (Rust PLAN_REDESIGNS §14.3.12, D370): the triggers that
|
||||
// begin a run of it. Bounded for the reason MAX_PHASES is — a paste guard, not a
|
||||
// policy, and ten is more than any event here has wanted.
|
||||
const MAX_STARTS = 10
|
||||
|
||||
// How long after one trigger-started run another may start. Thirty minutes by
|
||||
// default, zero (one run at a time is then the only bound) up to a week. It is
|
||||
// the same one-integer-one-unit grammar as `after`, for the same proofreading
|
||||
// reason, and normalised the same way.
|
||||
const DEFAULT_COOLDOWN = '30m'
|
||||
const MAX_COOLDOWN_SECONDS = 7 * 86_400
|
||||
|
||||
// Core's own lifecycle triggers cannot start a run (D370). `event.run.started`
|
||||
// starting a run would be a run starting another, and the one-at-a-time rule is
|
||||
// per definition, so it would not hold a chain of two definitions starting each
|
||||
// other. Refused by prefix because every one of them is core's and named so.
|
||||
const CORE_EVENT_PREFIX = 'event.'
|
||||
|
||||
// The four closed shapes of §E. `manual` is first because it is the default and
|
||||
// what an unscheduled draft carries; the other three are recurrences the runner
|
||||
// expands into occurrences ahead of time.
|
||||
@@ -323,6 +342,74 @@ function validateAdvance(raw, path, errors) {
|
||||
return { on: triggerId, where: checked.conditions, count, dormant: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cooldown into seconds, or answer null. `'0m'` (or a bare `0`) is
|
||||
* allowed here and nowhere else in the duration grammar: no cooldown at all is a
|
||||
* real choice, and then one run at a time is the only bound.
|
||||
*/
|
||||
function parseCooldown(raw) {
|
||||
if (raw === 0 || raw === '0') return 0
|
||||
const m = AFTER_RE.exec(String(raw ?? ''))
|
||||
if (!m) return null
|
||||
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
|
||||
return seconds <= MAX_COOLDOWN_SECONDS ? seconds : null
|
||||
}
|
||||
|
||||
const formatCooldown = (seconds) => (seconds === 0 ? '0m' : formatAfter(seconds))
|
||||
|
||||
/**
|
||||
* Check a definition's `starts` and answer the normalised list (D370).
|
||||
*
|
||||
* Each entry is a phase gate's `on` and `where` — the same catalogue, the same
|
||||
* grammar, the same dormant rule — plus a `cooldown`. A trigger nobody registers
|
||||
* makes the entry dormant: it saves, and it refuses to publish.
|
||||
*/
|
||||
function validateStarts(raw, errors) {
|
||||
if (raw === undefined || raw === null) return []
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push('spec.starts: expected an array')
|
||||
return []
|
||||
}
|
||||
if (raw.length > MAX_STARTS) {
|
||||
errors.push(`spec.starts: at most ${MAX_STARTS} triggers`)
|
||||
return []
|
||||
}
|
||||
|
||||
const out = []
|
||||
raw.forEach((entry, i) => {
|
||||
const path = `spec.starts[${i}]`
|
||||
if (!isPlainObject(entry)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return
|
||||
}
|
||||
const extra = Object.keys(entry).filter((k) => !['on', 'where', 'cooldown', 'dormant'].includes(k))
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
|
||||
return
|
||||
}
|
||||
if (typeof entry.on === 'string' && entry.on.startsWith(CORE_EVENT_PREFIX)) {
|
||||
errors.push(`${path}.on: "${entry.on}" is one of the event system's own triggers, and a run cannot start another`)
|
||||
return
|
||||
}
|
||||
|
||||
const cooldownSeconds = parseCooldown(entry.cooldown ?? DEFAULT_COOLDOWN)
|
||||
if (cooldownSeconds === null) {
|
||||
errors.push(
|
||||
`${path}.cooldown: expected a duration like "30m" — a whole number of s, m, h or d, ` +
|
||||
`from 0 to ${formatAfter(MAX_COOLDOWN_SECONDS)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The trigger and its predicate are a gate's, so a gate's validator answers
|
||||
// for them: one grammar, one dormant rule, one set of messages.
|
||||
const gate = validateAdvance({ on: entry.on, where: entry.where ?? null }, path, errors)
|
||||
if (!gate) return
|
||||
out.push({ on: gate.on, where: gate.where, cooldown: formatCooldown(cooldownSeconds), dormant: gate.dormant })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A `multiple` param's value as the list it is (MODULE_API 1.14.0, Rust
|
||||
* PLAN_REDESIGNS §12.3).
|
||||
@@ -427,7 +514,7 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
||||
|
||||
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
|
||||
|
||||
const allowed = new Set(['schedule', 'phases'])
|
||||
const allowed = new Set(['schedule', 'phases', 'starts'])
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowed.has(key)) {
|
||||
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
|
||||
@@ -452,6 +539,9 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
||||
schedule = validateSchedule(kind, rawSchedule, errors)
|
||||
}
|
||||
|
||||
// ── starts (D370) ──
|
||||
const starts = validateStarts(raw.starts, errors)
|
||||
|
||||
// ── phases ──
|
||||
const rawPhases = raw.phases
|
||||
const phases = []
|
||||
@@ -605,7 +695,10 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
||||
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, spec: { schedule, phases } }
|
||||
// `starts` is omitted when empty, for the reason a phase's `advance` is: a spec
|
||||
// carrying `"starts": []` everywhere is a version diff that says every
|
||||
// definition changed the first time one of them gained a trigger.
|
||||
return { ok: true, spec: starts.length ? { schedule, starts, phases } : { schedule, phases } }
|
||||
}
|
||||
|
||||
/** Every action id a spec names, dormant ones included. */
|
||||
@@ -630,6 +723,9 @@ function publishable(spec) {
|
||||
const dormant = [
|
||||
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
|
||||
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
|
||||
// A start on a trigger nothing registers, on the same rule (D370): a version
|
||||
// that starts on something nobody can emit is one that never starts.
|
||||
...(spec?.starts || []).filter((s) => s.dormant).map((s) => s.on),
|
||||
]
|
||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||
}
|
||||
@@ -651,7 +747,12 @@ module.exports = {
|
||||
defaultOnFailure,
|
||||
parseAfter,
|
||||
formatAfter,
|
||||
parseCooldown,
|
||||
PHASE_KEY,
|
||||
MAX_STARTS,
|
||||
DEFAULT_COOLDOWN,
|
||||
MAX_COOLDOWN_SECONDS,
|
||||
CORE_EVENT_PREFIX,
|
||||
SCHEDULE_KINDS,
|
||||
ADVANCE_KINDS,
|
||||
MAX_ADVANCE_COUNT,
|
||||
|
||||
210
server/src/events/starts.js
Normal file
210
server/src/events/starts.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// ── Starting a run from a trigger: the emit path's third subscriber ────────
|
||||
//
|
||||
// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
|
||||
// trigger". *"When anyone destroys Bradley on Main, start Launch Site
|
||||
// Lockdown"*; *"when a champion's boss appears, start Champion's Call"*. Before
|
||||
// this a trigger could only advance a phase of a run already going (`gates.js`)
|
||||
// or send a notice (`engine.dispatch`); now a definition's published version may
|
||||
// carry `starts`, and a firing that matches one creates a run of it, now.
|
||||
//
|
||||
// **Same seam and posture as the other two subscribers**: called from
|
||||
// `engagementEmit.emit`, never awaited, never allowed to reject. Whether some
|
||||
// event wanted to start is core's business, and a failure of core's must not
|
||||
// become the module's control flow.
|
||||
//
|
||||
// **Four rules decide whether a matching firing starts anything:**
|
||||
//
|
||||
// 1. The version must have passed its dry run. A trigger start is unattended,
|
||||
// as a scheduled one is, and §K's gate falls on the path where nobody is.
|
||||
// 2. One run at a time per definition. A firing while a run of it is going
|
||||
// starts nothing and is counted.
|
||||
// 3. A cooldown after the last trigger-started run (default 30 minutes, 0 to
|
||||
// 7 days). A firing inside it starts nothing and is counted. Rules 2 and 3
|
||||
// are what stop twelve road Bradleys dying in a minute starting twelve runs.
|
||||
// 4. Core's own `event.*` triggers never start a run (refused at save, and
|
||||
// skipped here as well), so a run starting can never start another.
|
||||
//
|
||||
// Rules 2 and 3 are ONE conditional UPDATE (`eventTriggerStarts.db.claim`); the
|
||||
// occurrence key `UNIQUE (definition_id, scope, scheduled_for)` with
|
||||
// `scheduled_for` = the firing's time is what makes one firing reported twice
|
||||
// (two instances ingesting one feed) create one run.
|
||||
|
||||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||||
const startsDb = require('../model/events/eventTriggerStarts.db')
|
||||
const runsModel = require('../model/events/eventRuns.model')
|
||||
const conditions = require('../engagement/conditions')
|
||||
const registries = require('../modules/registries')
|
||||
const spec = require('./spec')
|
||||
const gates = require('./gates')
|
||||
const log = require('../utils/logger')('event-starts')
|
||||
|
||||
// What of a firing's data is kept on the run. The run row is admin-only, and it
|
||||
// is the answer to "why did this run happen", so the trigger's DECLARED
|
||||
// variables are kept and nothing else a module happened to send. Bounded, so
|
||||
// one oversized payload cannot make the insert the thing that fails.
|
||||
const MAX_ORIGIN_BYTES = 4096
|
||||
|
||||
/** The firing's time, in whole seconds (the column's precision), never in the future. */
|
||||
function firingTime(occurredAt, now) {
|
||||
const parsed = occurredAt ? new Date(occurredAt) : now
|
||||
const at = Number.isNaN(parsed.getTime()) || parsed > now ? now : parsed
|
||||
return new Date(Math.floor(at.getTime() / 1000) * 1000)
|
||||
}
|
||||
|
||||
const sameSecond = (a, b) =>
|
||||
a && b && Math.floor(new Date(a).getTime() / 1000) === Math.floor(new Date(b).getTime() / 1000)
|
||||
|
||||
/** The firing as the run will remember it. */
|
||||
function originOf(event, declaration, start, at) {
|
||||
const declared = (declaration?.variables || []).map((v) => v.name)
|
||||
const data = event.data || {}
|
||||
let variables = Object.fromEntries(declared.filter((n) => n in data).map((n) => [n, data[n]]))
|
||||
if (Buffer.byteLength(JSON.stringify(variables), 'utf8') > MAX_ORIGIN_BYTES) {
|
||||
// Too big to keep whole: keep the ones the start's condition named, which
|
||||
// are the reason it matched, and say the rest were dropped.
|
||||
const named = gates.variablesIn(start.where)
|
||||
variables = Object.fromEntries(named.filter((n) => n in data).map((n) => [n, data[n]]))
|
||||
variables._truncated = true
|
||||
}
|
||||
return {
|
||||
kind: 'trigger',
|
||||
triggerId: event.triggerId,
|
||||
label: declaration?.label || event.triggerId,
|
||||
occurredAt: at.toISOString(),
|
||||
subject: event.subject ?? null,
|
||||
// The start's condition in the builder's own words, so the console can say
|
||||
// what it matched on without re-reading the version.
|
||||
matched: gates.phrase(start.where),
|
||||
variables,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a firing that matched did not get the claim. A duplicate of the firing
|
||||
* that started the last run is not counted: it is the same thing happening once,
|
||||
* reported twice.
|
||||
*/
|
||||
async function whyNot(definitionId, at, now) {
|
||||
const state = await startsDb.forDefinition(definitionId)
|
||||
if (state && sameSecond(state.last_started_at, at)) return 'duplicate'
|
||||
if (await startsDb.isGoing(definitionId, now)) return 'running'
|
||||
// A live claim is another firing starting it this instant: the run is going in
|
||||
// every sense but the row.
|
||||
if (state?.claimed_at && now - new Date(state.claimed_at) < startsDb.CLAIM_STALE_MS) return 'running'
|
||||
return 'cooldown'
|
||||
}
|
||||
|
||||
/** One definition, one firing. Answers what happened, for the summary and the tests. */
|
||||
async function consider(definition, event, at, now) {
|
||||
const starts = (definition.version_spec?.starts || []).filter((s) => s.on === event.triggerId && !s.dormant)
|
||||
const start = starts.find((s) => conditions.evaluate(s.where, event.data || {}))
|
||||
if (!start) return 'unmatched'
|
||||
|
||||
if (now - at > Number(definition.grace_seconds || 0) * 1000) {
|
||||
// A firing older than the grace window would make a run the missed sweep
|
||||
// takes on its next tick. Said, and not started.
|
||||
log.warn('a firing too old to start a run', { definition: definition.id, trigger: event.triggerId, at })
|
||||
return 'stale'
|
||||
}
|
||||
|
||||
if (!definition.version_verified_at) {
|
||||
await startsDb.ignore(definition.id, 'unverified', now)
|
||||
log.warn('trigger start held: the published version has never been verified', {
|
||||
definition: definition.id,
|
||||
title: definition.title,
|
||||
trigger: event.triggerId,
|
||||
})
|
||||
return 'unverified'
|
||||
}
|
||||
|
||||
const cooldownSeconds = spec.parseCooldown(start.cooldown ?? spec.DEFAULT_COOLDOWN) ?? 0
|
||||
if (!(await startsDb.claim(definition.id, { at, cooldownSeconds, now }))) {
|
||||
const reason = await whyNot(definition.id, at, now)
|
||||
if (reason !== 'duplicate') await startsDb.ignore(definition.id, reason, now)
|
||||
return reason
|
||||
}
|
||||
|
||||
const declaration = registries.eventTrigger(event.triggerId)
|
||||
let result
|
||||
try {
|
||||
result = await runsModel.create(
|
||||
definition.id,
|
||||
// Scope is empty, as a scheduled occurrence's is (org lead, 2026-09-02): a
|
||||
// scope registry does not exist, and the condition is what narrows a start
|
||||
// to one server.
|
||||
{ scope: '', scheduledFor: at, source: 'trigger', origin: originOf(event, declaration, start, at) },
|
||||
null,
|
||||
)
|
||||
} catch (err) {
|
||||
await startsDb.release(definition.id)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
await startsDb.release(definition.id)
|
||||
if (result.code === 'unverified') await startsDb.ignore(definition.id, 'unverified', now)
|
||||
log.warn('trigger start refused', { definition: definition.id, errors: result.errors })
|
||||
return 'refused'
|
||||
}
|
||||
|
||||
if (!result.created) {
|
||||
// The occurrence already existed: this firing was reported twice, and the
|
||||
// claimant that made the run has recorded it. Nothing to count.
|
||||
await startsDb.release(definition.id)
|
||||
return 'duplicate'
|
||||
}
|
||||
await startsDb.started(definition.id, { at, runId: result.run.id })
|
||||
log.info('run started by a trigger', { definition: definition.id, run: result.run.id, trigger: event.triggerId })
|
||||
return 'started'
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger stream's third subscriber.
|
||||
*
|
||||
* The common answer is one indexed-by-nothing query over the ready definitions
|
||||
* returning no rows: a deployment has tens of definitions, not thousands, and a
|
||||
* JSON column cannot carry an index. If that ever changes, a side table of
|
||||
* (trigger, definition) written at publish is the fix.
|
||||
*/
|
||||
async function observe(event, { now = new Date() } = {}) {
|
||||
const summary = { definitions: 0, started: 0, ignored: 0 }
|
||||
try {
|
||||
if (!event?.triggerId || event.triggerId.startsWith(spec.CORE_EVENT_PREFIX)) return summary
|
||||
const candidates = await definitionsDb.findStartingOn(event.triggerId)
|
||||
summary.definitions = candidates.length
|
||||
if (!candidates.length) return summary
|
||||
|
||||
const at = firingTime(event.occurredAt, now)
|
||||
for (const definition of candidates) {
|
||||
try {
|
||||
const outcome = await consider(definition, event, at, now)
|
||||
if (outcome === 'started') summary.started += 1
|
||||
if (['running', 'cooldown', 'unverified'].includes(outcome)) summary.ignored += 1
|
||||
} catch (err) {
|
||||
log.error('trigger start failed', { definition: definition.id, trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('trigger start observation failed', { trigger: event?.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's line about a definition's trigger starts, from the state row.
|
||||
* Null when no trigger has ever tried to start it.
|
||||
*/
|
||||
function describeState(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
lastStartedAt: row.last_started_at || null,
|
||||
lastRunId: row.last_run_id || null,
|
||||
ignoredRunning: Number(row.ignored_running || 0),
|
||||
ignoredCooldown: Number(row.ignored_cooldown || 0),
|
||||
ignoredUnverified: Number(row.ignored_unverified || 0),
|
||||
lastIgnoredAt: row.last_ignored_at || null,
|
||||
lastIgnoredReason: row.last_ignored_reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { observe, consider, describeState, firingTime, originOf, MAX_ORIGIN_BYTES }
|
||||
@@ -159,6 +159,30 @@ const findSchedulable = async ({ listedOnly = false } = {}) => {
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `ready` definition whose PUBLISHED version starts on this trigger
|
||||
* (Rust PLAN_REDESIGNS §14.3.12, D370). The emit path's question, asked on every
|
||||
* firing, so it answers in one query and the common answer is no rows.
|
||||
*
|
||||
* Read from the version, never the working copy, for the reason the schedule
|
||||
* expansion is: a start an author is midway through typing must not fire.
|
||||
* `JSON_EXTRACT` over `$.starts[*].on` is an array, or NULL for a version with
|
||||
* no starts, and `JSON_CONTAINS` of NULL is not true.
|
||||
*/
|
||||
const findStartingOn = async (triggerId) => {
|
||||
const rows = await query(
|
||||
`SELECT d.id, d.title, d.grace_seconds, d.current_version_id,
|
||||
v.spec AS version_spec, v.verified_at AS version_verified_at
|
||||
FROM event_definitions d
|
||||
JOIN event_versions v ON v.id = d.current_version_id
|
||||
WHERE d.state = 'ready'
|
||||
AND JSON_CONTAINS(JSON_EXTRACT(v.spec, '$.starts[*].on'), JSON_QUOTE(?))
|
||||
ORDER BY d.id`,
|
||||
[triggerId],
|
||||
)
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition by slug, for the PUBLIC event page (Phase 14a).
|
||||
*
|
||||
@@ -206,6 +230,7 @@ module.exports = {
|
||||
listPublicBySeries,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
findStartingOn,
|
||||
insert,
|
||||
update,
|
||||
markReady,
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
params: parseJson(row.params, null),
|
||||
origin: parseJson(row.origin, null),
|
||||
rehearsal: Boolean(row.rehearsal),
|
||||
}
|
||||
|
||||
// The statuses a run never leaves. A transition INTO one of these stamps
|
||||
// `ended_at` and drops the claim, and only rows in one of them are eligible for
|
||||
@@ -90,8 +96,8 @@ const materialise = async (run) => {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_runs
|
||||
(definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
|
||||
params, rehearsal, started_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
params, rehearsal, started_by, origin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
run.definition_id,
|
||||
run.version_id,
|
||||
@@ -102,6 +108,8 @@ const materialise = async (run) => {
|
||||
run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
|
||||
run.rehearsal ? 1 : 0,
|
||||
run.started_by,
|
||||
// Why the run exists when a trigger started it (D370); NULL otherwise.
|
||||
run.origin ? JSON.stringify(run.origin) : null,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
|
||||
@@ -69,7 +69,7 @@ function renderConcurrencyKey(template, params) {
|
||||
*/
|
||||
async function create(
|
||||
definitionId,
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual', origin = null } = {},
|
||||
userId,
|
||||
) {
|
||||
const definition = await definitionsDb.getById(definitionId)
|
||||
@@ -97,7 +97,10 @@ async function create(
|
||||
// nobody is. A version is immutable, so a dry run that passed against it stays
|
||||
// true, which is what makes the pass a property of the version rather than
|
||||
// something re-earned every occurrence.
|
||||
if (source === 'schedule' && !version.verified_at) {
|
||||
//
|
||||
// **A trigger start is unattended too** (D370): a boss dying at four in the
|
||||
// morning starts the run with nobody watching, exactly as a schedule does.
|
||||
if ((source === 'schedule' || source === 'trigger') && !version.verified_at) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
@@ -124,6 +127,7 @@ async function create(
|
||||
params,
|
||||
rehearsal,
|
||||
started_by: userId,
|
||||
origin,
|
||||
})
|
||||
|
||||
if (runId === null) {
|
||||
@@ -146,8 +150,11 @@ async function create(
|
||||
// a recurrence (Phase 4). Both produce the same row, and the log is the
|
||||
// only place the difference is recorded — `started_by` is NULL for both a
|
||||
// scheduled occurrence and one started by a since-deleted account.
|
||||
// 'trigger' is a game firing (D370), and it is the one source that also
|
||||
// says WHAT: the origin goes into the audit line as well as on the row.
|
||||
source,
|
||||
by: userId,
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
106
server/src/model/events/eventTriggerStarts.db.js
Normal file
106
server/src/model/events/eventTriggerStarts.db.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// ── event_trigger_starts — SQL only ────────────────────────────────────────
|
||||
//
|
||||
// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
|
||||
// trigger". One row per definition a trigger has tried to start. `events/starts.js`
|
||||
// decides; this file holds the claim and the counts.
|
||||
//
|
||||
// **The claim is one statement with every rule inside it.** A firing may start a
|
||||
// run only when no claim is live, the last trigger-started run is out of its
|
||||
// cooldown, and no run of the definition is going. Read-then-write would let two
|
||||
// firings arriving together each see a quiet definition and both start, which is
|
||||
// exactly the twelve-Bradleys case the rule exists for.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// A claim older than this belongs to a process that died between the claim and
|
||||
// the run row existing. A minute is far longer than an insert takes.
|
||||
const CLAIM_STALE_MS = 60_000
|
||||
|
||||
// What "a run is going" means for the one-at-a-time rule: started and not ended,
|
||||
// or scheduled and already due — a trigger-started run is `scheduled` for up to
|
||||
// one tick before the runner claims it, and it is going from the moment it exists.
|
||||
const GOING_SQL = `
|
||||
SELECT 1 FROM event_runs r
|
||||
WHERE r.definition_id = t.definition_id
|
||||
AND (r.status IN ('starting','running','paused','ending')
|
||||
OR (r.status = 'scheduled' AND r.scheduled_for <= ?))`
|
||||
|
||||
const REASONS = ['running', 'cooldown', 'unverified']
|
||||
|
||||
/** The row, or null when no trigger has ever tried to start this definition. */
|
||||
const forDefinition = async (definitionId) => {
|
||||
const [row] = await query('SELECT * FROM event_trigger_starts WHERE definition_id = ?', [definitionId])
|
||||
return row || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to own the start. `true` when this caller may create the run.
|
||||
*
|
||||
* `at` is the firing's time (whole seconds), `cooldownSeconds` the matching
|
||||
* start's own. The row is made first with `INSERT IGNORE`, so the UPDATE always
|
||||
* has something to guard.
|
||||
*/
|
||||
async function claim(definitionId, { at, cooldownSeconds, now }) {
|
||||
await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
|
||||
const result = await query(
|
||||
`UPDATE event_trigger_starts t
|
||||
SET t.claimed_at = ?
|
||||
WHERE t.definition_id = ?
|
||||
AND (t.claimed_at IS NULL OR t.claimed_at < ?)
|
||||
AND (t.last_started_at IS NULL OR t.last_started_at <= ?)
|
||||
AND NOT EXISTS (${GOING_SQL})`,
|
||||
[
|
||||
now,
|
||||
definitionId,
|
||||
new Date(now.getTime() - CLAIM_STALE_MS),
|
||||
new Date(at.getTime() - cooldownSeconds * 1000),
|
||||
now,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/** The run exists: record it, drop the claim, and start the counts again. */
|
||||
const started = (definitionId, { at, runId }) =>
|
||||
query(
|
||||
`UPDATE event_trigger_starts
|
||||
SET claimed_at = NULL, last_started_at = ?, last_run_id = ?,
|
||||
ignored_running = 0, ignored_cooldown = 0, ignored_unverified = 0,
|
||||
last_ignored_at = NULL, last_ignored_reason = NULL
|
||||
WHERE definition_id = ?`,
|
||||
[at, runId, definitionId],
|
||||
)
|
||||
|
||||
/** The claim came to nothing (the create refused): give it back. */
|
||||
const release = (definitionId) =>
|
||||
query('UPDATE event_trigger_starts SET claimed_at = NULL WHERE definition_id = ?', [definitionId])
|
||||
|
||||
/**
|
||||
* Count a firing that started nothing, by why. `reason` is one of REASONS and
|
||||
* names its own column, so it is checked against the list before it reaches SQL.
|
||||
*/
|
||||
async function ignore(definitionId, reason, now) {
|
||||
if (!REASONS.includes(reason)) throw new Error(`unknown ignore reason "${reason}"`)
|
||||
await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
|
||||
await query(
|
||||
`UPDATE event_trigger_starts
|
||||
SET ignored_${reason} = ignored_${reason} + 1, last_ignored_at = ?, last_ignored_reason = ?
|
||||
WHERE definition_id = ?`,
|
||||
[now, reason, definitionId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Is a run of this definition going right now? The ignore path's "why". */
|
||||
const isGoing = async (definitionId, now) => {
|
||||
const rows = await query(
|
||||
`SELECT 1 FROM event_runs r
|
||||
WHERE r.definition_id = ?
|
||||
AND (r.status IN ('starting','running','paused','ending')
|
||||
OR (r.status = 'scheduled' AND r.scheduled_for <= ?))
|
||||
LIMIT 1`,
|
||||
[definitionId, now],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
module.exports = { forDefinition, claim, started, release, ignore, isGoing, REASONS, CLAIM_STALE_MS }
|
||||
@@ -34,6 +34,8 @@ const activity = require('../../../model/activity/activity.model')
|
||||
const settingsDb = require('../../../model/events/eventActionSettings.db')
|
||||
const authorize = require('../../../events/authorize')
|
||||
const price = require('../../../events/price')
|
||||
const starts = require('../../../events/starts')
|
||||
const triggerStartsDb = require('../../../model/events/eventTriggerStarts.db')
|
||||
|
||||
const asId = (raw) => {
|
||||
const n = Number(raw)
|
||||
@@ -86,6 +88,9 @@ const shapeDefinition = (d) => ({
|
||||
const shapeEditable = async (row) => ({
|
||||
...shapeDefinition(row),
|
||||
draftAhead: await definitions.draftAhead(row),
|
||||
// What its trigger starts have done lately (D370): the last run one started
|
||||
// and the firings ignored since, by why. Null until a trigger first tries.
|
||||
triggerStarts: starts.describeState(await triggerStartsDb.forDefinition(row.id)),
|
||||
})
|
||||
|
||||
const shapeRun = (r) => ({
|
||||
@@ -105,6 +110,10 @@ const shapeRun = (r) => ({
|
||||
concurrencyKey: r.concurrency_key,
|
||||
params: r.params,
|
||||
rehearsal: r.rehearsal,
|
||||
// What started it, when a game firing did (D370): the trigger, when, its
|
||||
// subject, the condition it matched and its declared variables. Null for a
|
||||
// run started by hand or by the schedule.
|
||||
origin: r.origin || null,
|
||||
startedAt: r.started_at,
|
||||
endedAt: r.ended_at,
|
||||
// When the results table was ranked and published (Phase 10). On the LIST as
|
||||
@@ -223,6 +232,9 @@ exports.catalog = (_req, res) => {
|
||||
defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
|
||||
maxAdvanceCount: spec.MAX_ADVANCE_COUNT,
|
||||
maxAfterSeconds: spec.MAX_AFTER_SECONDS,
|
||||
// A definition's `starts` (D370): how many, and the cooldown's ceiling.
|
||||
maxStarts: spec.MAX_STARTS,
|
||||
maxCooldownSeconds: spec.MAX_COOLDOWN_SECONDS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ eventsRouter.get(
|
||||
'/runs/:runId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -439,7 +439,7 @@ eventsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One event definition, including its working spec'
|
||||
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.'
|
||||
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -24,6 +24,7 @@ const registries = require('../modules/registries')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
const engine = require('../engagement/engine')
|
||||
const eventGates = require('../events/gates')
|
||||
const eventStarts = require('../events/starts')
|
||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||
const createLogger = require('./logger')
|
||||
|
||||
@@ -320,6 +321,15 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
.observe(event)
|
||||
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
// **The third subscriber** (Rust PLAN_REDESIGNS §14.3.12, D370): a definition
|
||||
// whose published version starts on this trigger gets a run, now. Separate
|
||||
// from the gate observer for the same reason that one is separate from
|
||||
// `dispatch` — starting a run and advancing one decide different things, and
|
||||
// neither may fail the other.
|
||||
eventStarts
|
||||
.observe(event)
|
||||
.catch((err) => log.error('trigger start rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
return { ok: true, event }
|
||||
}
|
||||
|
||||
|
||||
@@ -4422,7 +4422,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
|
||||
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.",
|
||||
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -5703,7 +5703,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "One event definition, including its working spec",
|
||||
"description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.",
|
||||
"description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
|
||||
@@ -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