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:
@@ -1,6 +1,7 @@
|
||||
// ── The live run controls (EVENTS_PLAN.md Phase 3) ─────────────────────────
|
||||
//
|
||||
// Six controls, and what is tested is almost entirely the REFUSALS. A control
|
||||
// Seven controls as of Phase 5, and what is tested is almost entirely the
|
||||
// REFUSALS. A control
|
||||
// that works is easy; a control that works from a status it should not have
|
||||
// worked from is a staff member changing a live game world by pressing a button
|
||||
// a stale screen offered them. So each of the six is exercised from every status
|
||||
@@ -13,6 +14,8 @@
|
||||
// • confirm on a step a process is mid-dispatch on, not a parked cue
|
||||
// • skip on a step with a live lease
|
||||
// • cancel closing out a parked cue, so a cancelled run stops "waiting"
|
||||
// • advance on a phase that is NOT waiting on its gate — the refusal that
|
||||
// makes force-advance an override rather than a way to skip a phase's steps
|
||||
//
|
||||
// The three tables are stubbed at the `.db` layer and the model's own logic runs
|
||||
// for real against them — the shape `eventRunner.test.js` uses. What a stub
|
||||
@@ -31,6 +34,7 @@ const controls = require('../src/model/events/eventRunControls.model')
|
||||
const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||
const logDb = require('../src/model/events/eventRunLog.db')
|
||||
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -43,10 +47,11 @@ const originals = [
|
||||
['runs', runsDb, { ...runsDb }],
|
||||
['steps', stepsDb, { ...stepsDb }],
|
||||
['log', logDb, { ...logDb }],
|
||||
['gates', gatesDb, { ...gatesDb }],
|
||||
]
|
||||
|
||||
function installStubs() {
|
||||
store = { runs: new Map(), steps: new Map(), log: [], nextStepId: 1 }
|
||||
store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), nextStepId: 1, nextGateId: 1 }
|
||||
const snap = (o) => ({ ...o })
|
||||
|
||||
runsDb.getById = async (id) => {
|
||||
@@ -111,6 +116,18 @@ function installStubs() {
|
||||
return n
|
||||
}
|
||||
|
||||
// Phase 5's advance guard reads this. Unstubbed it is a real query, and this
|
||||
// file points the pool at a closed port — three tests would hang for ten
|
||||
// seconds each and then fail with ECONNREFUSED rather than saying anything
|
||||
// about the control. Same shape as the statement: pending and running only,
|
||||
// lowest seq first.
|
||||
stepsDb.nextOpenStep = async (runId, phase) => {
|
||||
const s = [...store.steps.values()]
|
||||
.filter((x) => x.run_id === Number(runId) && x.phase === phase && ['pending', 'running'].includes(x.status))
|
||||
.sort((a, b) => a.seq - b.seq || a.id - b.id)[0]
|
||||
return s ? { ...s } : null
|
||||
}
|
||||
|
||||
stepsDb.lastStartedSeq = async (runId, phase) => {
|
||||
const started = [...store.steps.values()]
|
||||
.filter((s) => s.run_id === Number(runId) && s.phase === phase && s.status !== 'pending')
|
||||
@@ -122,6 +139,19 @@ function installStubs() {
|
||||
store.log.push(line)
|
||||
return true
|
||||
}
|
||||
|
||||
// Phase 5. `satisfy` carries `WHERE satisfied_at IS NULL`, and the stub keeps
|
||||
// it: a gate that could be forced twice would log two advances of one phase.
|
||||
gatesDb.forPhase = async (runId, phase) => {
|
||||
const g = store.gates.get(`${Number(runId)}|${phase}`)
|
||||
return g ? { ...g } : null
|
||||
}
|
||||
gatesDb.satisfy = async (id, by, { userId = null, now = new Date() } = {}) => {
|
||||
const g = [...store.gates.values()].find((x) => x.id === id)
|
||||
if (!g || g.satisfied_at) return false
|
||||
Object.assign(g, { satisfied_at: now, satisfied_by: by, forced_by: userId })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(installStubs)
|
||||
@@ -131,6 +161,29 @@ afterEach(() => {
|
||||
|
||||
let nextRunId = 1
|
||||
|
||||
function seedGate(runId, phase, { kind = 'on', trigger = 'test.trigger', needed = 1, tally = 0, minutesAgo = 5 } = {}) {
|
||||
const g = {
|
||||
id: store.nextGateId++,
|
||||
run_id: runId,
|
||||
phase,
|
||||
kind,
|
||||
after_seconds: kind === 'after' ? 1800 : null,
|
||||
trigger_id: kind === 'on' ? trigger : null,
|
||||
conditions: null,
|
||||
needed,
|
||||
tally,
|
||||
entered_at: new Date(Date.now() - minutesAgo * 60_000),
|
||||
due_at: null,
|
||||
last_event: null,
|
||||
last_event_at: null,
|
||||
satisfied_at: null,
|
||||
satisfied_by: null,
|
||||
forced_by: null,
|
||||
}
|
||||
store.gates.set(`${runId}|${phase}`, g)
|
||||
return g
|
||||
}
|
||||
|
||||
function seedRun({ status = 'running', phase = 'main', steps = [] } = {}) {
|
||||
const id = nextRunId++
|
||||
store.runs.set(id, {
|
||||
@@ -168,6 +221,72 @@ const runRow = (id) => store.runs.get(id)
|
||||
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
|
||||
const lastLog = () => store.log[store.log.length - 1]
|
||||
|
||||
// ── advance (Phase 5) ──────────────────────────────────────────────────────
|
||||
|
||||
test('advance releases a phase that is genuinely waiting on its gate', async () => {
|
||||
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||
const gate = seedGate(id, 'main', { needed: 3, tally: 1, minutesAgo: 68 })
|
||||
|
||||
const result = await controls.advancePhase(id, { reason: 'the boss never spawned' }, ACTOR)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.phase, 'main')
|
||||
assert.equal(gate.satisfied_by, 'forced')
|
||||
assert.equal(gate.forced_by, ACTOR)
|
||||
|
||||
// The run is NOT transitioned here: the next tick does the phase boundary,
|
||||
// exactly as it does after resume, so there is one implementation of what a
|
||||
// phase boundary is rather than two.
|
||||
assert.equal(runRow(id).current_phase, 'main')
|
||||
|
||||
const line = lastLog()
|
||||
assert.equal(line.kind, 'phase.advanced')
|
||||
assert.equal(line.detail.because, 'forced')
|
||||
assert.equal(line.detail.by, ACTOR)
|
||||
assert.equal(line.detail.reason, 'the boss never spawned')
|
||||
assert.equal(line.detail.seen, 1)
|
||||
assert.equal(line.detail.needed, 3, 'and the log says what it was still waiting for')
|
||||
assert.ok(line.detail.waitedSeconds > 4000)
|
||||
})
|
||||
|
||||
test('advance is refused on a phase that is waiting on a STEP, not on its gate', async () => {
|
||||
// The refusal that makes this an override rather than a way to skip work: a
|
||||
// phase with an open step is held by the step, and skip is its control.
|
||||
const id = seedRun({ status: 'running', steps: [{ status: 'done' }, { status: 'pending', actionId: 'test.slow' }] })
|
||||
seedGate(id, 'main')
|
||||
|
||||
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 409)
|
||||
assert.match(result.errors[0], /waiting on step 1 \(test\.slow\)/)
|
||||
assert.equal(store.log.length, 0, 'and nothing is logged for a refusal')
|
||||
})
|
||||
|
||||
test('advance is refused when the phase has no advance condition at all', async () => {
|
||||
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors[0], /has no advance condition; skip its steps instead/)
|
||||
})
|
||||
|
||||
test('advance is refused from every status that is not `running`', async () => {
|
||||
for (const status of ['scheduled', 'starting', 'paused', 'ending', ...TERMINAL]) {
|
||||
const id = seedRun({ status, steps: [{ status: 'done' }] })
|
||||
seedGate(id, 'main')
|
||||
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||
assert.equal(result.ok, false, `a ${status} run should not be advanceable`)
|
||||
assert.match(result.errors[0], new RegExp(`a ${status} run has no phase to advance`))
|
||||
}
|
||||
})
|
||||
|
||||
test('advance twice is refused the second time', async () => {
|
||||
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||
seedGate(id, 'main')
|
||||
assert.equal((await controls.advancePhase(id, {}, ACTOR)).ok, true)
|
||||
const second = await controls.advancePhase(id, {}, ACTOR)
|
||||
assert.equal(second.ok, false)
|
||||
assert.match(second.errors[0], /already past its advance condition/)
|
||||
})
|
||||
|
||||
// ── pause / resume ─────────────────────────────────────────────────────────
|
||||
|
||||
test('pause takes a run in flight and records who did it', async () => {
|
||||
|
||||
Reference in New Issue
Block a user