feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s

A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
2026-09-02 22:11:20 -05:00
parent 9c23c5fd0e
commit 9bc0bf5a3d
26 changed files with 2646 additions and 51 deletions

View File

@@ -0,0 +1,187 @@
// ── event_run_phase_gates — SQL only ───────────────────────────────────────
//
// EVENTS.md §E, and Phase 5 of EVENTS_PLAN.md. A phase used to advance on one
// fact — every step terminal — and that fact lives in `event_run_steps`. An
// advance CONDITION is a second fact, and it is the only one in this feature
// that is not derivable from a row somebody already wrote: `{ on:
// 'uo.champ.boss_up', count: 3 }` counts things that happen between one tick and
// the next, and the runner is not running when they happen. A gate row is where
// a firing is counted at the moment it fires.
//
// **Two writers, and they are not the same process leg.** The RUNNER opens a
// gate (at phase entry) and closes an `after` one (when its deadline passes);
// the EMIT PATH increments and closes an `on` one. Everything here is therefore
// written as a single guarded statement rather than a read-then-write, which is
// the same argument `event_run_budget`'s conditional increment makes one phase
// early and the same one `runsDb.transition` makes for a status.
//
// **Nothing here throws at the emit path.** `observe` is called from inside a
// game-event handler by way of `ctx.events.emit`, exactly as `engine.dispatch`
// is, and a database problem of core's must not become a module's control flow.
// The catch lives in `events/gates.js`; this file is the statements.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) =>
row && {
...row,
conditions: parseJson(row.conditions, null),
last_event: parseJson(row.last_event, null),
}
/**
* Open a phase's gate. **INSERT IGNORE against `uq_evgate_phase`**, so a process
* that died between entering a phase and getting here opens no second gate on
* the next tick — the idempotence `materialisePhase` has, for the same reason.
*
* Answers whether a row was created, which is what lets the caller log
* `phase.entered`'s gate detail exactly once.
*/
async function open({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = new Date() }) {
// `due_at` is computed here, once, from the moment the phase was entered —
// never re-derived on a later tick from a `now` that has moved. A deadline
// recomputed every fifteen seconds is a deadline that never arrives.
const dueAt = kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null
const result = await query(
`INSERT IGNORE INTO event_run_phase_gates
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
runId,
phase,
kind,
afterSeconds,
triggerId,
conditions === null ? null : JSON.stringify(conditions),
needed,
now,
dueAt,
],
)
return Number(result?.affectedRows || 0) === 1
}
/** One run's gate for one phase, or null. */
const forPhase = async (runId, phase) =>
hydrate(
(
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? AND phase = ? LIMIT 1', [
runId,
phase,
])
)[0] || null,
)
/** Every gate a run has ever opened, oldest first — what the run console reads. */
const listForRun = async (runId) =>
(
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? ORDER BY entered_at, id', [
runId,
])
).map(hydrate)
/**
* Every OPEN gate waiting on one trigger, with the run's status and phase.
*
* This is the emit path's only query and the one index in this feature on a hot
* path. The join is what keeps a gate belonging to a cancelled run from counting
* for ever: a run that will never advance again must stop tallying, and its
* row's `satisfied_at` is not what says so.
*
* **`paused` counts.** The world does not stop because an operator paused the
* console, and discarding firings that arrived during a pause would make pause a
* destructive control — the tally an operator came back to would be lower than
* the one they left, with nothing recording the difference.
*/
const openForTrigger = async (triggerId) =>
(
await query(
`SELECT g.* FROM event_run_phase_gates g
JOIN event_runs r ON r.id = g.run_id
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
AND r.status IN ('running','paused')
AND r.current_phase = g.phase
LIMIT 200`,
[triggerId],
)
).map(hydrate)
/**
* Count one matching firing, and close the gate if that was the last one needed.
*
* **One statement, with the threshold inside it.** Two emits arriving together
* each add one and exactly one of them crosses `needed`; a read-then-write would
* let both see 2 of 3 and neither satisfy, or both satisfy and advance a phase
* twice. `WHERE satisfied_at IS NULL` is what makes a late arrival a no-op
* rather than a tally that keeps climbing after the phase moved on.
*
* Answers `{ counted, satisfied }` read back from the row, so the caller logs
* the tally the database actually holds rather than the one it predicted.
*/
async function count(gateId, { lastEvent = null, now = new Date() } = {}) {
// **THE INCREMENT MUST BE LAST, and this is not style.** MariaDB evaluates an
// UPDATE's SET assignments LEFT TO RIGHT, each one seeing the values already
// assigned by the ones before it — which is a documented departure from
// standard SQL, and it is invisible in a stub. With `tally = tally + 1` first,
// the CASE that follows reads the ALREADY-INCREMENTED tally, so `tally + 1 >=
// needed` is really `new + 1 >= needed` and a gate needing two firings closes
// on the first. Written this way, both CASEs see the old tally and say exactly
// what they read as. `eventRunnerSql.test.js` is what catches a reorder, and
// it is what caught this one.
const result = await query(
`UPDATE event_run_phase_gates
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
last_event = ?,
last_event_at = ?,
tally = tally + 1
WHERE id = ? AND satisfied_at IS NULL`,
[now, lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
)
if (Number(result?.affectedRows || 0) !== 1) return { counted: false, satisfied: false }
const row = await byId(gateId)
return { counted: true, satisfied: Boolean(row?.satisfied_at), tally: row?.tally ?? null }
}
/**
* Record that a firing was seen and did NOT match.
*
* Only `last_event_at` and `last_event` move: the tally is what the phase is
* waiting on, and a near miss is not progress. It is recorded at all because
* "the boss did spawn, in the wrong region" and "no boss has spawned" are
* different answers to the operator's question, and only this column can tell
* them apart on a screen.
*/
async function noteNearMiss(gateId, { lastEvent = null, now = new Date() } = {}) {
const result = await query(
`UPDATE event_run_phase_gates
SET last_event = ?, last_event_at = ?
WHERE id = ? AND satisfied_at IS NULL`,
[lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Close a gate for a reason that is not a matching firing: `'elapsed'` when an
* `after` deadline passed, `'forced'` when a human pressed advance.
*
* Guarded on `satisfied_at IS NULL` like everything else here, so a force that
* races the tick that would have opened the gate anyway loses harmlessly and the
* log records whichever actually happened rather than both.
*/
async function satisfy(gateId, by, { userId = null, now = new Date() } = {}) {
const result = await query(
`UPDATE event_run_phase_gates
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
WHERE id = ? AND satisfied_at IS NULL`,
[now, by, userId, gateId],
)
return Number(result?.affectedRows || 0) === 1
}
const byId = async (id) =>
hydrate((await query('SELECT * FROM event_run_phase_gates WHERE id = ? LIMIT 1', [id]))[0] || null)
module.exports = { open, forPhase, byId, listForRun, openForTrigger, count, noteNearMiss, satisfy }

View File

@@ -7,15 +7,18 @@
// needs no control, and a run that paused on a failed world write is the one
// that does.
//
// **Two of §I's six run-level controls are deliberately not here.**
// `advance` — force a phase forward — has no honest meaning yet: a phase today
// advances when its steps go terminal, and the per-step skip already does that
// one step at a time. Phase 5 is what gives a phase an `advance` CONDITION, and
// that is the first moment "force it anyway" means something an operator could
// predict. `cleanup` needs Phase 8's resource ledger; there is nothing to
// revert, so cancel takes `{ reason }` and gains `cleanup` when there is
// something for it to do. Both are absent rather than inert, which is the
// posture Phase 1 set and Phase 2 kept.
// **`advance` is the seventh, and it arrived in Phase 5 rather than Phase 3
// because that is when it started meaning something.** A phase used to advance
// when its steps went terminal and on nothing else, so "force it anyway" named
// no state an operator could be in; a phase with a gate can wait for a boss that
// will never spawn, and then it names exactly one. It is the other half of the
// diagnosis panel: a screen that explains why a phase has not started, beside a
// control that does something about it.
//
// **One of §I's controls is still not here.** `cleanup` needs Phase 8's resource
// ledger; there is nothing to revert, so cancel takes `{ reason }` and gains
// `cleanup` when there is something for it to do. Absent rather than inert,
// which is the posture Phase 1 set and every phase since has kept.
//
// **Every control is guarded on the status it may act from, and the guard is a
// WHERE clause rather than a read-then-write.** A run console rendered thirty
@@ -33,6 +36,8 @@
const runsDb = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const gatesDb = require('./eventPhaseGates.db')
const gates = require('../../events/gates')
const MAX_REASON = 500
@@ -168,6 +173,67 @@ async function cancel(runId, { reason } = {}, userId = null) {
return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
}
/**
* Force a phase forward: open its gate without the condition that would have.
*
* **It is legal only when the phase is actually waiting on a gate**, and the
* three refusals are the whole design. A run that is not `running` is not
* waiting on anything (409 naming what it is). A phase with no gate advances on
* its steps and always has, so forcing it would be a control that duplicated the
* runner rather than overriding it. And a phase whose steps have not all gone
* terminal is not being held by its gate — it is being held by a step, and the
* step-level skip is the honest control for that, one step at a time. A force
* that swept past pending steps would be a cancel of half a phase under a button
* labelled advance.
*
* **It satisfies the gate and stops.** The next tick advances the run, exactly
* as it does after `resume` — the phase transition, the next phase's
* materialisation, its own gate and the log lines are one sequence in
* `advanceRun`, and a second copy of it here would be a second opinion about
* what a phase boundary is. The response says which phase was released, so the
* console can say so before the tick lands.
*/
async function advancePhase(runId, { reason } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status !== 'running') return conflict(`a ${run.status} run has no phase to advance`)
if (!run.current_phase) return conflict('this run has not entered a phase yet')
const gate = await gatesDb.forPhase(run.id, run.current_phase)
if (!gate) return conflict(`phase "${run.current_phase}" has no advance condition; skip its steps instead`)
if (gate.satisfied_at) return conflict(`phase "${run.current_phase}" is already past its advance condition`)
const openStep = await stepsDb.nextOpenStep(run.id, run.current_phase)
if (openStep) {
return conflict(
`phase "${run.current_phase}" is waiting on step ${openStep.seq} (${openStep.action_id}), not on its advance condition`,
)
}
const note = clean(reason)
if (!(await gatesDb.satisfy(gate.id, 'forced', { userId }))) {
return conflict('this phase stopped waiting on its advance condition')
}
const described = gates.describe(gate)
await logDb.write({
runId: run.id,
kind: 'phase.advanced',
phase: run.current_phase,
detail: {
because: 'forced',
control: 'advance',
by: userId,
reason: note,
waitedSeconds: described.elapsedSeconds,
...(gate.kind === 'on'
? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed }
: { after: gate.after_seconds }),
},
})
return { ok: true, run: await runsDb.getById(run.id), phase: run.current_phase }
}
// ── Step-level ────────────────────────────────────────────────────────────
/**
@@ -293,4 +359,4 @@ async function retryStep(runId, stepId, options = {}, userId = null) {
}
}
module.exports = { pause, resume, cancel, confirmStep, skipStep, retryStep }
module.exports = { pause, resume, cancel, advancePhase, confirmStep, skipStep, retryStep }

View File

@@ -33,6 +33,13 @@ const KINDS = [
'step.retry', // a step failed transiently and will be attempted again
'step.parked', // a step is waiting on a human and nothing is holding it
'phase.completed', // every step of a phase reached a terminal status
// Phase 5's four. `condition.evaluated` is written for BOTH outcomes (§
// Observability), and the non-matching one is the more valuable of the two on
// the night: "the boss did spawn, in Britain" and "no boss has spawned" are
// different answers to the same question and look identical without it.
'phase.gate', // a phase opened an advance gate, with what it waits for
'condition.evaluated', // a firing was tested against a gate, matched or not
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }

View File

@@ -23,6 +23,16 @@ const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), r
// only if every path a run can take reaches one of them.
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
// §E's health values, worst last. Health is a HIGH-WATER MARK in this system —
// nothing has ever cleared `degraded`, because a run whose announcement landed
// on the second attempt did have trouble and that stays true for the rest of its
// life — and `setHealth` enforces that rather than leaving it to every caller to
// remember. `FIELD()` gives the same order inside the WHERE clause, 1-indexed,
// which is what makes the guard one statement rather than a read and a write.
const HEALTH_ORDER = ['ok', 'degraded', 'stalled']
const HEALTH_RANK = Object.fromEntries(HEALTH_ORDER.map((h, i) => [h, i + 1]))
const HEALTH_SQL_ORDER = HEALTH_ORDER.map((h) => `'${h}'`).join(', ')
// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
// pair `park()` alone produces, which means a cue waiting on a human. It is a
// correlated subquery on an admin list bounded at 500 rows rather than a column,
@@ -359,11 +369,20 @@ const statusOf = async (id) => {
* same degradation does not restamp `updated_at`.
*/
async function setHealth(id, health) {
const result = await query('UPDATE event_runs SET health = ? WHERE id = ? AND health <> ?', [
health,
id,
health,
])
// **Escalation only, and this is the guard rather than a convention.** Health
// has always been a high-water mark here — `degraded` is never cleared,
// because a run whose announcement landed on the second attempt DID have
// trouble and that stays true — and Phase 5 gave the column a second writer
// for `stalled`. Without a rank, a step that retried after a stall would
// quietly demote `stalled` to `degraded` and a run that waited ninety minutes
// on a boss that never came would end its life claiming it merely wobbled.
const rank = HEALTH_RANK[health]
if (!rank) return false
const result = await query(
`UPDATE event_runs SET health = ?
WHERE id = ? AND FIELD(health, ${HEALTH_SQL_ORDER}) < ?`,
[health, id, rank],
)
return Number(result?.affectedRows || 0) === 1
}

View File

@@ -21,6 +21,8 @@
const db = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const gatesDb = require('./eventPhaseGates.db')
const gates = require('../../events/gates')
const definitionsDb = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
@@ -140,12 +142,30 @@ async function create(
return { ok: true, created: true, run: await db.getById(runId) }
}
/** A run, its steps and its status counts — what the run console reads. */
/**
* A run, its steps, its status counts and its phase gates — the run console.
*
* The gates arrive already DESCRIBED rather than as rows (Phase 5): the panel's
* whole value is that it reads the way the condition builder reads, and those
* words come from `engagement/conditions.js`'s own operator labels. Rendering
* them in the browser would be a second implementation of a grammar the server
* owns, and the first clause the two spelled differently would meet its operator
* at two in the morning.
*
* Every gate the run has opened is returned, not only the current phase's. A
* completed phase's gate answers "how long did phase 2 actually wait, and what
* released it" — which is the same question as the live one, asked afterwards.
*/
async function detail(runId) {
const run = await db.getById(runId)
if (!run) return null
const [steps, counts] = await Promise.all([stepsDb.listForRun(runId), stepsDb.statusCounts(runId)])
return { run, steps, counts }
const [steps, counts, gateRows] = await Promise.all([
stepsDb.listForRun(runId),
stepsDb.statusCounts(runId),
gatesDb.listForRun(runId),
])
const now = new Date()
return { run, steps, counts, gates: gateRows.map((g) => gates.describe(g, now)) }
}
module.exports = { create, detail, renderConcurrencyKey }