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