feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
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:
@@ -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 “phase 3 has not started” 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)
|
||||
|
||||
Reference in New Issue
Block a user