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

@@ -522,6 +522,8 @@ export const api = {
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
cancelEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }),
advanceEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
confirmEventStep: (runId, stepId, note) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
skipEventStep: (runId, stepId, reason) =>

View File

@@ -47,14 +47,27 @@ export function lastStartedSeqOf(steps, phase) {
* happen is cancelled, not paused. `cancel` is everything non-terminal — "this
* is not happening" is a decision made before a run starts as often as during
* one.
*
* **`advance` is offered only when the phase is genuinely waiting on its gate**,
* which is the same test the server makes and is stated here in the same words
* on purpose: this decides what is *offered*, the server decides what is
* *allowed*, and a button that is present and always refused is the "control
* that answers 409 and does nothing" this feature has refused twice. The gate
* must be open-and-unsatisfied AND no step of the phase may still be pending or
* running — a phase held by a step is held by the step, and skip is its control.
*/
export function runControlsFor(run) {
if (!run) return { pause: false, resume: false, cancel: false }
export function runControlsFor(run, gates = [], steps = []) {
if (!run) return { pause: false, resume: false, cancel: false, advance: false }
const terminal = isTerminalRun(run.status)
const gate = (gates || []).find((g) => g.phase === run.currentPhase)
const stepOpen = (steps || []).some(
(s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status),
)
return {
pause: ['starting', 'running'].includes(run.status),
resume: run.status === 'paused',
cancel: !terminal,
advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
}
}
@@ -125,7 +138,41 @@ export function blankStep(action) {
}
export function blankPhase(phases) {
return { key: nextPhaseKey(phases), label: 'New phase', steps: [] }
return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() }
}
/**
* The advance gate as the FORM holds it (Phase 5) — three fields that are
* always present and mostly empty, rather than a discriminated union the form
* has to rebuild every time the dropdown moves.
*
* `kind: ''` is "no condition", which is what nearly every phase is and what
* every phase was before this. The form keeps a half-typed `on` gate's trigger
* while the author looks at `after`, because a dropdown that discards what was
* typed under the other option is one an operator learns to be afraid of.
*/
export function blankAdvance() {
return { kind: '', after: '30m', on: '', count: 1, whereText: '' }
}
export const ADVANCE_KINDS = [
{ value: '', label: 'When its steps are done' },
{ value: 'after', label: 'After a fixed delay' },
{ value: 'on', label: 'When something happens in the game' },
]
/** The stored gate, as the form's three fields. */
export function advanceFormFrom(advance) {
const blank = blankAdvance()
if (!advance) return blank
if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after }
return {
...blank,
kind: 'on',
on: advance.on || '',
count: advance.count ?? 1,
whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '',
}
}
/** The editor's working state, from what `GET /admin/events/:id` returned. */
@@ -145,6 +192,7 @@ export function formFromDefinition(event) {
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
advance: advanceFormFrom(p.advance),
steps: (p.steps || []).map((s) => ({
actionId: s.actionId || '',
label: s.label || '',
@@ -157,6 +205,31 @@ export function formFromDefinition(event) {
}
}
/**
* One phase's advance gate, as the spec shape — or null when it has none.
*
* Only the `where` JSON is checked, and only because text that is not JSON
* cannot be put in a request at all. **Whether the predicate is VALID is the
* server's answer**, and the whole trap of Phase 5 is that it is answered at
* save with the offending variable named — re-deciding it here would be a second
* validator drifting from the one that matters, exactly as with a step's params.
*/
export function advancePayload(advance, where, errors) {
if (!advance || !advance.kind) return null
if (advance.kind === 'after') return { after: advance.after }
const out = { on: advance.on, count: Number(advance.count) || 1 }
const text = String(advance.whereText || '').trim()
if (text) {
try {
out.where = JSON.parse(text)
} catch (err) {
errors.push(`${where}, advance condition: ${err.message}`)
}
}
return out
}
/**
* The form, as a request body — or the list of everything wrong with it.
*
@@ -173,9 +246,16 @@ export function formFromDefinition(event) {
*/
export function payloadFromForm(form) {
const errors = []
const phases = (form.phases || []).map((phase, pi) => ({
const phases = (form.phases || []).map((phase, pi) => {
const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors)
return {
key: phase.key,
label: phase.label,
// Omitted rather than sent as null when there is no gate, which is what
// `events/spec.js` stores for the same reason: a spec full of
// `"advance": null` makes the first phase to gain one look like an edit to
// every phase in the version diff.
...(where ? { advance: where } : {}),
steps: (phase.steps || []).map((step, si) => {
const out = { actionId: step.actionId }
if (step.label) out.label = step.label
@@ -188,7 +268,8 @@ export function payloadFromForm(form) {
}
return out
}),
}))
}
})
if (errors.length) return { ok: false, errors }
@@ -376,6 +457,9 @@ const KIND_WORDS = {
'step.status': 'Step',
'step.retry': 'Step retried',
'step.parked': 'Waiting on a human',
'phase.gate': 'Advance condition set',
'condition.evaluated': 'Condition evaluated',
'phase.advanced': 'Phase advanced',
note: 'Note',
}
@@ -415,6 +499,21 @@ export function describeLogLine(line) {
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
case 'run.created':
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
case 'phase.gate':
return d.kind === 'after'
? `${line.phase} advances ${d.after} after it started`
: `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}`
// Both outcomes are logged, and the near miss is the useful one: it is the
// difference between "the boss did spawn, in the wrong region" and "no boss
// has spawned", which look identical on every other line of this log.
case 'condition.evaluated':
return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${
d.satisfied ? ', condition met' : ''
}`
case 'phase.advanced':
return d.because === 'forced'
? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
: `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
default:
return logKindWord(line?.kind)
}

View File

@@ -7,6 +7,8 @@ import {
formFromDefinition,
payloadFromForm,
blankPhase,
blankAdvance,
ADVANCE_KINDS,
blankStep,
describeSchedule,
scheduleFromForm,
@@ -105,6 +107,12 @@ export default function EventEditor() {
const actions = useMemo(() => catalog?.actions || [], [catalog])
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
// Phase 5. Served with the actions on the same route, so an EDITOR sees the
// same catalog an admin does — `/admin/engagement/triggers` is admin-only, and
// an editor writing a trigger id from memory into a field the save path then
// refuses is the failure this avoids.
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
@@ -467,10 +475,77 @@ export default function EventEditor() {
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
The key is what the run console groups by and what &ldquo;phase 3 has not started&rdquo; names, so it
cannot change once runs exist. A phase advances when every one of its steps is finished;
advancing on a condition instead is a later phase.
cannot change once runs exist.
</p>
{/* ── The advance condition (Phase 5) ──
A gate is an ADDITIONAL condition and never a replacement, which is
what the caption has to say: a phase whose steps are still running
is not advanced by a boss that spawned early. */}
<div style={{ marginTop: 12, borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">This phase advances</span>
<select className="input" value={phase.advance?.kind || ''}
onChange={(e) => setPhase(pi, { advance: { ...(phase.advance || blankAdvance()), kind: e.target.value } })}>
{ADVANCE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</label>
{phase.advance?.kind === 'after' && (
<label style={{ flex: '0 1 160px' }}>
<span className="field-label">Delay</span>
<input className="input" value={phase.advance.after}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, after: e.target.value } })}
placeholder="30m" />
</label>
)}
{phase.advance?.kind === 'on' && (
<>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Trigger</span>
<select className="input" value={phase.advance.on}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, on: e.target.value } })}>
<option value="">Choose a trigger…</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
</select>
</label>
<label style={{ flex: '0 1 110px' }}>
<span className="field-label">How many</span>
<input className="input" type="number" min="1" value={phase.advance.count}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, count: e.target.value } })} />
</label>
</>
)}
</div>
{phase.advance?.kind === 'on' && (
<label style={{ display: 'block', marginTop: 10 }}>
<span className="field-label">Only when (JSON, optional)</span>
<textarea className="input" rows={3} spellCheck={false} value={phase.advance.whereText}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, whereText: e.target.value } })}
placeholder={'{ "variable": "region", "cmp": "eq", "value": "Yew" }'} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
A raw JSON field, and a placeholder for the same reason the params box is one — the
condition builder proper is a later phase. It is checked at save against what the
trigger declares, and a variable the trigger does not have comes back named.
{triggerById.get(phase.advance.on)?.variables?.length > 0 && (
<>
{' '}
<code>{phase.advance.on}</code> declares{' '}
{triggerById.get(phase.advance.on).variables.map((v) => `${v.name} (${v.type})`).join(', ')}.
</>
)}
</span>
</label>
)}
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
{phase.advance?.kind
? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
: 'The phase advances the moment every one of its steps is finished.'}
</p>
</div>
<div style={{ marginTop: 12 }}>
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)

View File

@@ -29,6 +29,15 @@ import {
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
// stay that way for ever unless somebody presses confirm. It is called out above
// the step list rather than being one row in it.
//
// **Phase 5 gave it a second one of those, and the panel is this phase's real
// deliverable** (§ Observability): a phase whose steps have all finished and
// whose advance condition has not been met is also `running` and also
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
// steps, in the condition builder's own words — and the sentence is the
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
// defined in the condition grammar and a second renderer in the browser would
// be a second opinion about what `gte` reads as.
const POLL_MS = 5000
@@ -52,11 +61,89 @@ const STEP_COLOR = {
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
/**
* Seconds as an operator reads them — the same vocabulary the spec authors a
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
* obviously the same kind of thing.
*/
function elapsed(seconds) {
const s = Math.max(0, Number(seconds) || 0)
if (s < 60) return `${s} sec`
if (s < 3600) return `${Math.floor(s / 60)} min`
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return m ? `${h} hr ${m} min` : `${h} hr`
}
/**
* One phase gate, as the panel draws it.
*
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
* was released by the third boss" is the same question as the live one, asked
* after the fact, and it is the one an operator asks the morning after.
*/
function GateRow({ gate, current }) {
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
return (
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
Phase <strong>{gate.phase}</strong>
{current && !gate.satisfied ? ' has not started' : ''}
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
{gate.stalled && ' — STALLED'}
</div>
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
{gate.kind === 'after' ? (
<>
<dt className="dim">waiting for</dt>
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
<dt className="dim">until</dt>
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
</>
) : (
<>
<dt className="dim">waiting on</dt>
<dd style={{ margin: 0 }}>
<code>{gate.waitingOn}</code>
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> — any firing</span>}
</dd>
<dt className="dim">seen so far</dt>
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
</>
)}
<dt className="dim">since</dt>
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
{gate.kind === 'on' && gate.lastEvent && (
<>
<dt className="dim">last related event</dt>
<dd style={{ margin: 0 }}>
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
{' — '}
{/* The near miss is the valuable half: "the boss did spawn, in
Britain" and "no boss has spawned" are different answers and
look identical without this line. */}
{gate.lastEvent.matched ? 'counted' : 'did not count'}
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
<span className="dim">
{' ('}
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
{')'}
</span>
)}
</dd>
</>
)}
</dl>
</div>
)
}
export default function EventRun() {
const { runId } = useParams()
const [run, setRun] = useState(null)
const [steps, setSteps] = useState([])
const [counts, setCounts] = useState({})
const [gates, setGates] = useState([])
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -75,6 +162,7 @@ export default function EventRun() {
setRun(detail.run)
setSteps(detail.steps || [])
setCounts(detail.counts || {})
setGates(detail.gates || [])
setLines(log.log || [])
}, [runId])
@@ -130,7 +218,8 @@ export default function EventRun() {
if (error) return <ErrorState message={error} />
if (!run) return <ErrorState message="No such run." />
const controls = runControlsFor(run)
const controls = runControlsFor(run, gates, steps)
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
const parked = steps.filter(isParked)
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
@@ -206,6 +295,10 @@ export default function EventRun() {
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
Resume
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.advance}
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
Advance phase
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
Cancel run
@@ -219,6 +312,36 @@ export default function EventRun() {
</p>
)}
{/* ── Why this phase has not started (Phase 5) ──
Above the step list for the same reason the parked cue is: a phase
waiting on a condition is `running` and looks completely healthy, and
the one screen an operator opens to find out why nothing is happening
must say so before they have to read a log. */}
{gates.length > 0 && (
<div
className="panel-flat"
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
</h3>
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
{waiting ? (
<>
Every step of this phase has finished. It advances when the condition below is met —
nothing times out, and <em>Advance phase</em> is how a person overrides it.
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
</>
) : (
'What each phase of this run waited for, and what released it.'
)}
</p>
{gates.map((gate) => (
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
))}
</div>
)}
{/* ── Waiting on a person ── */}
{parked.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>