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

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