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:
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user