feat(events): a trigger can start a run
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 12m45s

Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's
published version may carry `starts`: a trigger, a condition and a
cooldown. A matching firing creates a run of it, now.

- spec: `starts` validated by the phase gate's own function (same
  grammar, same dormant rule; a dormant start refuses publish), a
  cooldown from 0m to 7d (30m default), at most ten, event.* refused.
- events/starts.js: the emit path's third subscriber, beside dispatch
  and the gate observer. An unverified version starts nothing; one run
  at a time per definition and the cooldown are one conditional UPDATE
  on the new event_trigger_starts table; the occurrence key makes one
  firing reported twice one run. Ignored firings are counted by why.
- event_runs.origin: the firing (trigger, label, time, subject, matched
  condition, declared variables), also on the run.created audit line.
- Editor: a "Started by the game" panel using the gate's condition
  builder, with the ignored-firings line. Run console: "Started because
  ... fired".
- No module contract change and no MODULE_API bump.

Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a
real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the
dev database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-16 01:51:55 -05:00
parent eb773693d3
commit c9e992588b
18 changed files with 1352 additions and 20 deletions

View File

@@ -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,