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

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

View File

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

View File

@@ -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 } : {}),
},
})

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