feat(events): a trigger can start a run
Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's published version may carry `starts`: a trigger, a condition and a cooldown. A matching firing creates a run of it, now. - spec: `starts` validated by the phase gate's own function (same grammar, same dormant rule; a dormant start refuses publish), a cooldown from 0m to 7d (30m default), at most ten, event.* refused. - events/starts.js: the emit path's third subscriber, beside dispatch and the gate observer. An unverified version starts nothing; one run at a time per definition and the cooldown are one conditional UPDATE on the new event_trigger_starts table; the occurrence key makes one firing reported twice one run. Ignored firings are counted by why. - event_runs.origin: the firing (trigger, label, time, subject, matched condition, declared variables), also on the run.created audit line. - Editor: a "Started by the game" panel using the gate's condition builder, with the ignored-firings line. Run console: "Started because ... fired". - No module contract change and no MODULE_API bump. Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the dev database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user