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:
@@ -16,8 +16,12 @@ import {
|
||||
scheduleFormFrom,
|
||||
scheduleFromForm,
|
||||
isProjected,
|
||||
blankAdvance,
|
||||
advanceFormFrom,
|
||||
advancePayload,
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTHS,
|
||||
ADVANCE_KINDS,
|
||||
} from '../src/lib/eventAuthoring.js'
|
||||
|
||||
// lib/eventAuthoring.js — what the three Events screens say and what they let
|
||||
@@ -416,3 +420,140 @@ test('a projection is told apart from a run, because only one of them can be act
|
||||
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
|
||||
assert.equal(isProjected(null), false)
|
||||
})
|
||||
|
||||
|
||||
// -- The advance gate (Phase 5) ---------------------------------------------
|
||||
//
|
||||
// What this screen must get right is what it OFFERS. `advance` is the one
|
||||
// control in this feature whose whole point is that it overrides the engine, so
|
||||
// a button offered in a state the server refuses would be the "control that
|
||||
// answers 409 and does nothing" this feature has refused twice.
|
||||
|
||||
test('advance is offered only when the phase is waiting on its gate', () => {
|
||||
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
|
||||
const done = [{ phase: 'boss', status: 'done' }]
|
||||
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
|
||||
|
||||
// A phase with an open step is held by the STEP, and skip is its control.
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
|
||||
false,
|
||||
)
|
||||
|
||||
// A phase with no gate advances on its steps and always has.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
|
||||
// A gate already satisfied is not waiting.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
|
||||
// And a run that is not running is waiting on nothing.
|
||||
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('runControlsFor still answers with no gates or steps at all', () => {
|
||||
// The three Phase 3 controls were called with one argument for two phases, and
|
||||
// the calendar still calls it that way.
|
||||
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
|
||||
assert.equal(controls.pause, true)
|
||||
assert.equal(controls.advance, false)
|
||||
})
|
||||
|
||||
test('a gate round-trips through the form without losing the other shape', () => {
|
||||
assert.deepEqual(advanceFormFrom(null), blankAdvance())
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
|
||||
|
||||
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
|
||||
assert.equal(on.kind, 'on')
|
||||
assert.equal(on.count, 3)
|
||||
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
|
||||
|
||||
// The dropdown's three options, and the empty one is what nearly every phase
|
||||
// is — so it is first and it is not called "none".
|
||||
assert.equal(ADVANCE_KINDS[0].value, '')
|
||||
})
|
||||
|
||||
test('advancePayload sends one shape, and only reports a JSON error', () => {
|
||||
const errors = []
|
||||
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
|
||||
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
|
||||
assert.deepEqual(
|
||||
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', whereText: '' }, 'Phase 1', errors),
|
||||
{ on: 'uo.champ.boss_up', count: 2 },
|
||||
'an empty predicate is omitted, not sent as an empty object',
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{ not json' }, 'Phase 2 "Boss"', errors)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
|
||||
|
||||
// Whether the predicate is VALID is the server's answer, named variable and
|
||||
// all. This only refuses text that cannot be put in a request.
|
||||
const clean = []
|
||||
assert.deepEqual(
|
||||
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
|
||||
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
|
||||
)
|
||||
assert.equal(clean.length, 0)
|
||||
})
|
||||
|
||||
test('a phase with no gate sends no `advance` key', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.equal('advance' in built.payload.spec.phases[0], false)
|
||||
})
|
||||
|
||||
test('an authored gate survives the round trip through the form', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
|
||||
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.phases[0].advance, {
|
||||
on: 'uo.champ.boss_up',
|
||||
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
count: 2,
|
||||
})
|
||||
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
|
||||
})
|
||||
|
||||
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
|
||||
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
|
||||
)
|
||||
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
|
||||
/did not count — 0 of 2/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
|
||||
/counted — 2 of 2, condition met/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
|
||||
/boss advanced by hand after 4080s: never spawned/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
|
||||
/loot advanced on its deadline after 600s/,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user