feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s

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:
2026-09-02 22:11:20 -05:00
parent 9c23c5fd0e
commit 9bc0bf5a3d
26 changed files with 2646 additions and 51 deletions

View File

@@ -36,6 +36,8 @@ const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
const versionsDb = require('../src/model/events/eventVersions.db')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
const gates = require('../src/events/gates')
const db = require('../src/utils/db')
after(() => db.close())
@@ -48,7 +50,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
let store
const originals = {}
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb]]) {
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb]]) {
originals[name] = { mod, fns: { ...mod } }
}
@@ -66,7 +68,9 @@ function installStubs() {
log: [],
versions: new Map(),
definitions: new Map(),
gates: new Map(),
nextStepId: 1,
nextGateId: 1,
}
// Phase 4 put a schedule-expansion leg in front of the tick. This file is
@@ -142,9 +146,15 @@ function installStubs() {
runsDb.statusOf = async (id) => store.runs.get(id)?.status || null
// **Escalation only**, which is the statement's own guard rather than a
// convenience of this stub: `FIELD(health, 'ok','degraded','stalled') < rank`.
// A stub that let health move backwards would make a `stalled` run quietly
// become `degraded` again on the next retry, in the tests only.
const HEALTH_RANK = { ok: 1, degraded: 2, stalled: 3 }
runsDb.setHealth = async (id, health) => {
const r = store.runs.get(id)
if (!r || r.health === health) return false
if (!r || !HEALTH_RANK[health]) return false
if (HEALTH_RANK[r.health] >= HEALTH_RANK[health]) return false
r.health = health
return true
}
@@ -282,6 +292,88 @@ function installStubs() {
logDb.pruneTerminal = async () => 0
versionsDb.getById = async (id) => store.versions.get(id) || null
// ── The phase gates (Phase 5) ────────────────────────────────────────────
//
// `open` is INSERT IGNORE against (run_id, phase), and `count`/`satisfy` both
// carry `WHERE satisfied_at IS NULL` — the stub reproduces the guards rather
// than the convenience, because a gate that could be satisfied twice would
// advance a phase twice and no test here would see it.
const gateKey = (runId, phase) => `${runId}|${phase}`
gatesDb.open = async ({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = T0 }) => {
const key = gateKey(runId, phase)
if (store.gates.has(key)) return false
store.gates.set(key, {
id: store.nextGateId++,
run_id: runId,
phase,
kind,
after_seconds: afterSeconds,
trigger_id: triggerId,
conditions,
needed,
tally: 0,
entered_at: now,
due_at: kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null,
last_event: null,
last_event_at: null,
satisfied_at: null,
satisfied_by: null,
forced_by: null,
})
return true
}
gatesDb.forPhase = async (runId, phase) => {
const g = store.gates.get(gateKey(runId, phase))
return g ? { ...g } : null
}
gatesDb.byId = async (id) => {
const g = [...store.gates.values()].find((x) => x.id === id)
return g ? { ...g } : null
}
gatesDb.listForRun = async (runId) =>
[...store.gates.values()].filter((g) => g.run_id === runId).map((g) => ({ ...g }))
gatesDb.openForTrigger = async (triggerId) =>
[...store.gates.values()]
.filter((g) => g.trigger_id === triggerId && !g.satisfied_at)
.filter((g) => {
const r = store.runs.get(g.run_id)
return r && ['running', 'paused'].includes(r.status) && r.current_phase === g.phase
})
.map((g) => ({ ...g }))
gatesDb.count = async (id, { lastEvent = null, now = T0 } = {}) => {
const g = [...store.gates.values()].find((x) => x.id === id)
if (!g || g.satisfied_at) return { counted: false, satisfied: false }
g.tally += 1
g.last_event = lastEvent
g.last_event_at = now
if (g.tally >= g.needed) {
g.satisfied_at = now
g.satisfied_by = 'condition'
}
return { counted: true, satisfied: Boolean(g.satisfied_at), tally: g.tally }
}
gatesDb.noteNearMiss = async (id, { lastEvent = null, now = T0 } = {}) => {
const g = [...store.gates.values()].find((x) => x.id === id)
if (!g || g.satisfied_at) return false
g.last_event = lastEvent
g.last_event_at = now
return true
}
gatesDb.satisfy = async (id, by, { userId = null, now = T0 } = {}) => {
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
}
}
// ── Fixtures ───────────────────────────────────────────────────────────────
@@ -323,6 +415,7 @@ const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params,
const run = (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 kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind)
const gateOf = (id, phase) => store.gates.get(`${id}|${phase}`)
// A registered test action whose behaviour the test dictates.
let scripted
@@ -746,3 +839,199 @@ test('a resumed run picks up from the step it stopped at', async () => {
await runner.tick(T0)
assert.equal(stepsOf(other)[0].status, 'pending', 'a paused run is not swept')
})
// ── Phase 5: advance conditions ────────────────────────────────────────────
//
// The claim: a phase with a gate waits for it, a phase without one does not
// change at all, and NOTHING advances a phase but its condition or a human.
test('an `after` gate holds a phase whose steps are all done, and releases it on its deadline', async () => {
register([scriptedAction('test.a'), scriptedAction('test.b')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { after: '30m' } },
{ key: 'two', label: 'Two', steps: [step('test.b')] },
])
await runner.tick(T0)
assert.equal(run(id).status, 'running')
assert.equal(run(id).current_phase, 'one', 'the phase did not advance on its steps alone')
assert.equal(stepsOf(id)[0].status, 'done', 'but its step ran')
assert.equal(scripted['test.b'], undefined, 'and the next phase has not started')
// A tick a minute later changes nothing: the deadline is computed once, at
// entry, and is not re-derived from a `now` that has moved.
await runner.tick(later(60_000))
assert.equal(run(id).current_phase, 'one')
await runner.tick(later(30 * 60_000 + 1))
assert.equal(run(id).status, 'completed')
assert.equal(gateOf(id, 'one').satisfied_by, 'elapsed')
const line = store.log.find((l) => l.runId === id && l.kind === 'phase.advanced')
assert.equal(line.detail.because, 'elapsed')
assert.ok(line.detail.waitedSeconds >= 1800, 'the log says how long it actually waited')
})
test('a gate is in ADDITION to the steps, never instead of them', async () => {
// The gate opens immediately; the phase must still not advance, because a
// phase whose steps are still running is not finished. The near miss is a
// reading under which a boss that spawned early would carry a run past an
// announcement that had not been made.
register([scriptedAction('test.slow', { perform: async () => ({ ok: true, await: 'human' }) }), scriptedAction('test.b')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.slow')], advance: { after: '1s' } },
{ key: 'two', label: 'Two', steps: [step('test.b')] },
])
await runner.tick(T0)
await runner.tick(later(60_000))
assert.equal(run(id).current_phase, 'one')
assert.equal(gateOf(id, 'one').satisfied_at, null, 'the gate was never even consulted')
assert.equal(scripted['test.b'], undefined)
})
test('a phase with no gate advances exactly as it did before Phase 5', async () => {
register([scriptedAction('test.a'), scriptedAction('test.b')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.a')] },
{ key: 'two', label: 'Two', steps: [step('test.b')] },
])
await runner.tick(T0)
assert.equal(run(id).status, 'completed')
assert.equal(store.gates.size, 0, 'and no gate row was written for it')
})
test('an `on` gate counts firings from the emit path, and needs all of them', async () => {
register([scriptedAction('test.a'), scriptedAction('test.b')])
const id = seedRun([
{
key: 'one',
label: 'One',
steps: [step('test.a')],
advance: { on: 'test.trigger', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 },
},
{ key: 'two', label: 'Two', steps: [step('test.b')] },
])
await runner.tick(T0)
assert.equal(run(id).current_phase, 'one')
const fire = (region) =>
gates.observe({ triggerId: 'test.trigger', occurredAt: T0.toISOString(), subject: null, data: { region } })
// The near miss: it is recorded, it is logged, and it does not count.
await fire('Britain')
assert.equal(gateOf(id, 'one').tally, 0, 'a firing the condition rejects is not progress')
assert.equal(gateOf(id, 'one').last_event.matched, false, 'but it IS recorded — "wrong region" and "nothing happened" are different answers')
await fire('Yew')
assert.equal(gateOf(id, 'one').tally, 1)
assert.equal(gateOf(id, 'one').satisfied_at, null, 'one of two is not two')
await runner.tick(later(1000))
assert.equal(run(id).current_phase, 'one', 'and the runner agrees')
await fire('Yew')
assert.equal(gateOf(id, 'one').satisfied_by, 'condition')
await runner.tick(later(2000))
assert.equal(run(id).status, 'completed')
const evaluated = store.log.filter((l) => l.runId === id && l.kind === 'condition.evaluated')
assert.equal(evaluated.length, 3, 'every firing is logged, matched or not')
assert.deepEqual(evaluated.map((l) => l.detail.matched), [false, true, true])
assert.deepEqual(evaluated.map((l) => l.detail.seen), [0, 1, 2], 'and the tally logged is the one the row holds')
})
test('a phase forced by a human is not logged as advanced twice', async () => {
// `phase.advanced` is written by whoever made the DECISION. The advance
// control writes it with the actor and the reason; a second line from the
// tick that then acts on the satisfied gate made the console show the phase
// advancing twice, the less informative one last. Found in the live walk.
register([scriptedAction('test.a')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { on: 'test.trigger', count: 1 } },
{ key: 'two', label: 'Two', steps: [] },
])
await runner.tick(T0)
// What the control does: satisfy the gate as `forced` and stop.
await gatesDb.satisfy(gateOf(id, 'one').id, 'forced', { userId: 7, now: later(1000) })
await runner.tick(later(2000))
const advanced = store.log.filter((l) => l.runId === id && l.kind === 'phase.advanced')
assert.equal(advanced.length, 0, 'the tick writes no line of its own for a decision it did not make')
assert.equal(run(id).current_phase, 'two', 'and it still advances the phase')
})
test('a firing that arrives after the gate closed does not keep counting', async () => {
register([scriptedAction('test.a')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { on: 'test.trigger', count: 1 } },
{ key: 'two', label: 'Two', steps: [] },
])
await runner.tick(T0)
await gates.observe({ triggerId: 'test.trigger', occurredAt: T0.toISOString(), data: {} })
assert.equal(gateOf(id, 'one').tally, 1)
await gates.observe({ triggerId: 'test.trigger', occurredAt: T0.toISOString(), data: {} })
assert.equal(gateOf(id, 'one').tally, 1, 'the guard is `WHERE satisfied_at IS NULL`, not a read-then-write')
})
test('an `on` gate that waits past the threshold marks the run stalled, once', async () => {
register([scriptedAction('test.a')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { on: 'test.trigger', count: 1 } },
{ key: 'two', label: 'Two', steps: [] },
])
await runner.tick(T0)
assert.equal(run(id).health, 'ok', 'a phase that has just begun waiting is not stalled')
await runner.tick(later(gates.STALL_MS + 1000))
assert.equal(run(id).health, 'stalled')
await runner.tick(later(gates.STALL_MS + 60_000))
const health = store.log.filter((l) => l.runId === id && l.kind === 'run.health')
assert.equal(health.length, 1, 'and it is logged once, not once per tick')
})
test('an `after` gate is never stalled, however long it was authored to wait', async () => {
register([scriptedAction('test.a')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { after: '2d' } },
{ key: 'two', label: 'Two', steps: [] },
])
await runner.tick(T0)
await runner.tick(later(gates.STALL_MS * 3))
assert.equal(run(id).health, 'ok', 'a phase waiting out the delay it was given is working, not stalled')
})
test('health only ever escalates, so a retry after a stall does not demote it', async () => {
assert.equal(await runsDb.setHealth(seedRun([{ key: 'main', label: 'Main', steps: [] }]), 'degraded'), true)
const id = seedRun([{ key: 'main', label: 'Main', steps: [] }])
assert.equal(await runsDb.setHealth(id, 'stalled'), true)
assert.equal(await runsDb.setHealth(id, 'degraded'), false, 'a later degradation cannot undo a stall')
assert.equal(run(id).health, 'stalled')
assert.equal(await runsDb.setHealth(id, 'ok'), false, 'and nothing returns a run to healthy')
})
test('re-entering a phase does not open a second gate', async () => {
// The INSERT IGNORE that makes recovery from a died-mid-entry process
// uneventful — the same property `materialisePhase` has.
register([scriptedAction('test.a')])
const id = seedRun([
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { after: '1h' } },
{ key: 'two', label: 'Two', steps: [] },
])
await runner.tick(T0)
const entered = gateOf(id, 'one').entered_at
await runner.tick(later(60_000))
await runner.tick(later(120_000))
assert.equal(store.gates.size, 1)
assert.equal(gateOf(id, 'one').entered_at, entered, 'and the deadline it was given does not move')
})