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:
267
server/test/eventGates.test.js
Normal file
267
server/test/eventGates.test.js
Normal file
@@ -0,0 +1,267 @@
|
||||
// ── Phase advance gates (EVENTS_PLAN.md Phase 5) ───────────────────────────
|
||||
//
|
||||
// The runner's half of this is in `eventRunner.test.js`, where a gate holds a
|
||||
// phase and lets it go. This file is the other half — the two things that only
|
||||
// exist because an operator has to READ them:
|
||||
//
|
||||
// • `phrase()`, which renders a condition tree in the CONDITION BUILDER's own
|
||||
// words. § Observability's claim is that `gte` says "is at least" on the
|
||||
// diagnosis panel because it says "is at least" in the rule editor. That is
|
||||
// a claim about two files agreeing, so it is tested against the grammar's
|
||||
// own labels rather than against a string this file wrote down.
|
||||
// • `observe()`, whose whole reason for existing is that a firing between two
|
||||
// ticks is not observable from either of them — and whose near-miss branch
|
||||
// is the more valuable of its two outcomes on the night.
|
||||
//
|
||||
// **`describe()` is tested for what it does NOT say as much as what it does.**
|
||||
// An `after` gate is never stalled, however long it was authored to wait: a
|
||||
// phase waiting out six hours it was told to wait is working, and health that
|
||||
// said otherwise would train an operator to ignore it.
|
||||
//
|
||||
// Point the DB at a closed port before requiring anything.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const gates = require('../src/events/gates')
|
||||
const conditions = require('../src/engagement/conditions')
|
||||
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||
const logDb = require('../src/model/events/eventRunLog.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const T0 = new Date('2026-09-02T20:31:04Z')
|
||||
|
||||
// ── phrase(): the words are the grammar's, not this file's ─────────────────
|
||||
|
||||
test('every operator renders with the label the condition grammar declares', () => {
|
||||
// Not a table of expected strings: that would be a second copy of the labels,
|
||||
// and the point of rendering server-side is that there is only one.
|
||||
for (const [cmp, operator] of Object.entries(conditions.OPERATORS)) {
|
||||
const leaf =
|
||||
operator.arity === 0
|
||||
? { variable: 'region', cmp }
|
||||
: operator.arity === 'list'
|
||||
? { variable: 'region', cmp, value: ['Yew', 'Britain'] }
|
||||
: { variable: 'region', cmp, value: 'Yew' }
|
||||
const rendered = gates.phrase(leaf)
|
||||
assert.ok(rendered.startsWith(`region ${operator.label}`), `${cmp} should read as "${operator.label}"`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a tree reads as a sentence, and only brackets where the reading changes', () => {
|
||||
assert.equal(gates.phrase({ variable: 'region', cmp: 'eq', value: 'Yew' }), 'region is "Yew"')
|
||||
assert.equal(
|
||||
gates.phrase({ op: 'and', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }, { variable: 'level', cmp: 'gte', value: 3 }] }),
|
||||
'region is "Yew" and level is at least 3',
|
||||
'a flat and is a sentence, not a nest of brackets',
|
||||
)
|
||||
assert.equal(
|
||||
gates.phrase({
|
||||
op: 'or',
|
||||
nodes: [
|
||||
{ op: 'and', nodes: [{ variable: 'a', cmp: 'present' }, { variable: 'b', cmp: 'in', value: ['x', 'y'] }] },
|
||||
{ variable: 'c', cmp: 'absent' },
|
||||
],
|
||||
}),
|
||||
'(a is present and b is one of "x", "y") or c is absent',
|
||||
)
|
||||
assert.equal(gates.phrase({ op: 'not', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }] }), 'not (region is "Yew")')
|
||||
})
|
||||
|
||||
test('no conditions renders as nothing, not as "always true"', () => {
|
||||
// The caller says "on any firing of this trigger"; a clause claiming
|
||||
// everything is true is one more thing to read past.
|
||||
assert.equal(gates.phrase(null), null)
|
||||
assert.equal(gates.phrase({ variable: 'x', cmp: 'nonsense', value: 1 }), null)
|
||||
})
|
||||
|
||||
test('variablesIn names each variable once, in the order the tree names them', () => {
|
||||
const tree = {
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
{ op: 'or', nodes: [{ variable: 'level', cmp: 'gte', value: 3 }, { variable: 'region', cmp: 'ne', value: 'Britain' }] },
|
||||
],
|
||||
}
|
||||
assert.deepEqual(gates.variablesIn(tree), ['region', 'level'])
|
||||
assert.deepEqual(gates.variablesIn(null), [])
|
||||
})
|
||||
|
||||
// ── describe(): what the panel shows ───────────────────────────────────────
|
||||
|
||||
const gateRow = (over = {}) => ({
|
||||
id: 1,
|
||||
run_id: 1,
|
||||
phase: 'boss',
|
||||
kind: 'on',
|
||||
after_seconds: null,
|
||||
trigger_id: 'uo.champ.boss_up',
|
||||
conditions: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
needed: 1,
|
||||
tally: 0,
|
||||
entered_at: T0,
|
||||
due_at: null,
|
||||
last_event: null,
|
||||
last_event_at: null,
|
||||
satisfied_at: null,
|
||||
satisfied_by: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
test('the panel answers "why didn\'t phase 3 start?" with the tally, the clock and the clause', () => {
|
||||
const at = new Date(T0.getTime() + 28 * 60_000)
|
||||
const described = gates.describe(gateRow(), at)
|
||||
assert.equal(described.phase, 'boss')
|
||||
assert.equal(described.waitingOn, 'uo.champ.boss_up')
|
||||
assert.equal(described.where, 'region is "Yew"')
|
||||
assert.equal(described.seen, 0)
|
||||
assert.equal(described.needed, 1)
|
||||
assert.equal(described.elapsedSeconds, 28 * 60)
|
||||
assert.equal(described.satisfied, false)
|
||||
assert.equal(described.stalled, false, '28 minutes is not yet a stall')
|
||||
})
|
||||
|
||||
test('a satisfied gate stops its clock at the moment it was satisfied', () => {
|
||||
// Live, `elapsedSeconds` answers "how long has this phase been waiting";
|
||||
// afterwards it answers "how long did it wait", and those are the same number
|
||||
// only while it is still waiting. The walk found it disagreeing with
|
||||
// `phase.advanced`'s own `waitedSeconds` by the age of the open screen.
|
||||
const gate = gateRow({ satisfied_at: new Date(T0.getTime() + 121_000), satisfied_by: 'forced' })
|
||||
const muchLater = new Date(T0.getTime() + 3 * 3600_000)
|
||||
assert.equal(gates.describe(gate, muchLater).elapsedSeconds, 121)
|
||||
// And an open one still measures to now.
|
||||
assert.equal(gates.describe(gateRow(), new Date(T0.getTime() + 300_000)).elapsedSeconds, 300)
|
||||
})
|
||||
|
||||
test('an `after` gate is never stalled, however long it was told to wait', () => {
|
||||
const gate = gateRow({
|
||||
kind: 'after',
|
||||
trigger_id: null,
|
||||
conditions: null,
|
||||
after_seconds: 6 * 3600,
|
||||
due_at: new Date(T0.getTime() + 6 * 3600_000),
|
||||
})
|
||||
const described = gates.describe(gate, new Date(T0.getTime() + gates.STALL_MS * 4))
|
||||
assert.equal(described.stalled, false)
|
||||
assert.equal(described.after, 6 * 3600)
|
||||
assert.equal(described.waitingOn, undefined, 'and it carries none of the `on` fields')
|
||||
})
|
||||
|
||||
test('an `on` gate is stalled once it has waited past the threshold, and never once satisfied', () => {
|
||||
const late = new Date(T0.getTime() + gates.STALL_MS + 1000)
|
||||
assert.equal(gates.describe(gateRow(), late).stalled, true)
|
||||
assert.equal(gates.describe(gateRow({ satisfied_at: T0, satisfied_by: 'condition' }), late).stalled, false)
|
||||
})
|
||||
|
||||
// ── observe(): the emit path ───────────────────────────────────────────────
|
||||
|
||||
let store
|
||||
|
||||
function installStubs() {
|
||||
store = { gates: [], log: [] }
|
||||
gatesDb.openForTrigger = async (triggerId) => store.gates.filter((g) => g.trigger_id === triggerId && !g.satisfied_at)
|
||||
gatesDb.count = async (id, { lastEvent, now }) => {
|
||||
const g = store.gates.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
|
||||
return { counted: true, satisfied: Boolean(g.satisfied_at), tally: g.tally }
|
||||
}
|
||||
gatesDb.noteNearMiss = async (id, { lastEvent, now }) => {
|
||||
const g = store.gates.find((x) => x.id === id)
|
||||
if (!g) return false
|
||||
g.last_event = lastEvent
|
||||
g.last_event_at = now
|
||||
return true
|
||||
}
|
||||
logDb.write = async (line) => {
|
||||
store.log.push(line)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const originals = [
|
||||
[gatesDb, { ...gatesDb }],
|
||||
[logDb, { ...logDb }],
|
||||
]
|
||||
beforeEach(installStubs)
|
||||
afterEach(() => {
|
||||
for (const [mod, fns] of originals) Object.assign(mod, fns)
|
||||
})
|
||||
|
||||
const emit = (data, triggerId = 'uo.champ.boss_up') =>
|
||||
gates.observe({ triggerId, occurredAt: T0.toISOString(), subject: 'champ:yew', data })
|
||||
|
||||
test('a matching firing counts, and a near miss is recorded without counting', async () => {
|
||||
store.gates.push(gateRow({ needed: 2 }))
|
||||
|
||||
await emit({ region: 'Britain', level: 4 })
|
||||
assert.equal(store.gates[0].tally, 0)
|
||||
assert.equal(store.gates[0].last_event.matched, false)
|
||||
|
||||
await emit({ region: 'Yew', level: 4 })
|
||||
assert.equal(store.gates[0].tally, 1)
|
||||
assert.equal(store.gates[0].satisfied_at, null)
|
||||
|
||||
await emit({ region: 'Yew', level: 1 })
|
||||
assert.equal(store.gates[0].tally, 2)
|
||||
assert.ok(store.gates[0].satisfied_at)
|
||||
})
|
||||
|
||||
test('only the variables the condition names are recorded, never the payload', async () => {
|
||||
// The row is read back onto an admin screen, and a copy of a whole game
|
||||
// event's data is a second copy of exactly the content `engagement_sends` is
|
||||
// careful not to keep.
|
||||
store.gates.push(gateRow())
|
||||
await emit({ region: 'Yew', playerName: 'Dupre', houseLocation: '1423,1712', level: 4 })
|
||||
|
||||
assert.deepEqual(store.gates[0].last_event.variables, { region: 'Yew' })
|
||||
const line = store.log.find((l) => l.kind === 'condition.evaluated')
|
||||
assert.deepEqual(line.detail.variables, { region: 'Yew' })
|
||||
assert.equal(JSON.stringify(store.log).includes('Dupre'), false)
|
||||
assert.equal(JSON.stringify(store.gates).includes('1423,1712'), false)
|
||||
})
|
||||
|
||||
test('both outcomes are logged, with the tally the row actually holds', async () => {
|
||||
store.gates.push(gateRow({ needed: 2 }))
|
||||
await emit({ region: 'Britain' })
|
||||
await emit({ region: 'Yew' })
|
||||
|
||||
const lines = store.log.filter((l) => l.kind === 'condition.evaluated')
|
||||
assert.equal(lines.length, 2)
|
||||
assert.deepEqual(lines.map((l) => l.detail.matched), [false, true])
|
||||
assert.deepEqual(lines.map((l) => l.detail.seen), [0, 1])
|
||||
assert.deepEqual(lines.map((l) => l.detail.satisfied), [false, false])
|
||||
})
|
||||
|
||||
test('a gate with no `where` counts every firing of its trigger', async () => {
|
||||
store.gates.push(gateRow({ conditions: null }))
|
||||
await emit({ anything: true })
|
||||
assert.equal(store.gates[0].tally, 1)
|
||||
assert.deepEqual(store.gates[0].last_event.variables, {}, 'and there are no named variables to record')
|
||||
})
|
||||
|
||||
test('a firing of another trigger touches nothing', async () => {
|
||||
store.gates.push(gateRow())
|
||||
const summary = await emit({ region: 'Yew' }, 'uo.champ.started')
|
||||
assert.equal(summary.gates, 0)
|
||||
assert.equal(store.gates[0].tally, 0)
|
||||
assert.equal(store.log.length, 0)
|
||||
})
|
||||
|
||||
test('observe never rejects, however badly the database is behaving', async () => {
|
||||
// It is called from inside a game-event handler by way of `ctx.events.emit`,
|
||||
// exactly as `engine.dispatch` is. A database problem of core's must not
|
||||
// become a module's control flow at three in the morning.
|
||||
gatesDb.openForTrigger = async () => {
|
||||
throw new Error('pool exhausted')
|
||||
}
|
||||
const summary = await emit({ region: 'Yew' })
|
||||
assert.deepEqual(summary, { gates: 0, counted: 0, satisfied: 0 })
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -54,6 +54,32 @@
|
||||
// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no
|
||||
// series.
|
||||
//
|
||||
// **Phase 5 added the gate statements**, and the increment is the one on this
|
||||
// list with the closest precedent: engagement's cooldown claim was green against
|
||||
// its stub and always allowed the send against a real server, because the
|
||||
// connector defaults `foundRows: true`. A gate's tally is the same shape of
|
||||
// claim — a conditional UPDATE whose answer decides something — so it is proved
|
||||
// here rather than believed.
|
||||
//
|
||||
// * **`GATE_COUNT`** - `tally = tally + 1` with `CASE WHEN tally + 1 >=
|
||||
// needed` inside the same statement, guarded on `satisfied_at IS NULL`. Two
|
||||
// emits arriving together must each add one and exactly ONE of them must
|
||||
// cross the threshold; a read-then-write would let both see 2 of 3. **This
|
||||
// is the statement that failed here first**: MariaDB evaluates SET
|
||||
// assignments left to right with the values already assigned, so an
|
||||
// increment written before the CASE made the CASE read the new tally and
|
||||
// close a two-firing gate on the first firing. The ORDER of the SET list is
|
||||
// load-bearing, and only a real server says so.
|
||||
// * **`GATE_SATISFY`** - the same guard for the two reasons that are not a
|
||||
// firing: an `after` deadline passing, and a human forcing it. A force that
|
||||
// races the tick must lose harmlessly.
|
||||
// * **`GATE_OPEN_FOR_TRIGGER`** - the emit path's only query, and the one
|
||||
// index in this feature on a hot path. Its JOIN is what stops a gate
|
||||
// belonging to a cancelled run counting for ever.
|
||||
// * **`uq_evgate_phase`** - what makes `open` an INSERT IGNORE, so a process
|
||||
// that died between entering a phase and opening its gate opens no second
|
||||
// one on the next tick.
|
||||
//
|
||||
// Plus the two unique indexes that are load-bearing rather than tidy:
|
||||
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
|
||||
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
|
||||
@@ -147,6 +173,29 @@ CREATE TABLE event_run_steps (
|
||||
UNIQUE KEY uq_evstep_slot (run_id, phase, seq),
|
||||
INDEX idx_evstep_due (status, due_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_run_phase_gates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
kind ENUM('after','on') NOT NULL,
|
||||
after_seconds INT NULL,
|
||||
trigger_id VARCHAR(96) NULL,
|
||||
conditions JSON NULL,
|
||||
needed INT NOT NULL DEFAULT 1,
|
||||
tally INT NOT NULL DEFAULT 0,
|
||||
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
due_at DATETIME NULL,
|
||||
last_event JSON NULL,
|
||||
last_event_at DATETIME NULL,
|
||||
satisfied_at DATETIME NULL,
|
||||
satisfied_by VARCHAR(16) NULL,
|
||||
forced_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`
|
||||
|
||||
// The statements under test, verbatim from `eventRuns.db.js` and
|
||||
@@ -228,6 +277,34 @@ const LAST_STARTED_SEQ = `
|
||||
SELECT MAX(seq) AS seq FROM event_run_steps
|
||||
WHERE run_id = ? AND phase = ? AND status <> 'pending'`
|
||||
|
||||
// Phase 5's four, verbatim from `eventPhaseGates.db.js`.
|
||||
const GATE_OPEN = `
|
||||
INSERT IGNORE INTO event_run_phase_gates
|
||||
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
const GATE_COUNT = `
|
||||
UPDATE event_run_phase_gates
|
||||
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
|
||||
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
|
||||
last_event = ?,
|
||||
last_event_at = ?,
|
||||
tally = tally + 1
|
||||
WHERE id = ? AND satisfied_at IS NULL`
|
||||
|
||||
const GATE_SATISFY = `
|
||||
UPDATE event_run_phase_gates
|
||||
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
|
||||
WHERE id = ? AND satisfied_at IS NULL`
|
||||
|
||||
const GATE_OPEN_FOR_TRIGGER = `
|
||||
SELECT g.* FROM event_run_phase_gates g
|
||||
JOIN event_runs r ON r.id = g.run_id
|
||||
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
|
||||
AND r.status IN ('running','paused')
|
||||
AND r.current_phase = g.phase
|
||||
LIMIT 200`
|
||||
|
||||
const MATERIALISE_RUN = `
|
||||
INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
@@ -341,6 +418,7 @@ const rows = (r) => Number(r.affectedRows)
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!available) return
|
||||
await pool.query('DELETE FROM event_run_phase_gates')
|
||||
await pool.query('DELETE FROM event_run_steps')
|
||||
await pool.query('DELETE FROM event_runs')
|
||||
await pool.query('DELETE FROM event_definitions')
|
||||
@@ -891,3 +969,131 @@ test('a scheduled run whose started_at is somehow set is left alone', async (t)
|
||||
const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0]
|
||||
assert.equal(Number(after.version_id), 3)
|
||||
})
|
||||
|
||||
|
||||
// ── Phase 5: the gate statements ───────────────────────────────────────────
|
||||
|
||||
/** A run in `running` on one phase, plus an open gate for it. */
|
||||
async function seedGate({ kind = 'on', needed = 1, trigger = 'uo.champ.boss_up', status = 'running', phase = 'boss' } = {}) {
|
||||
const { runId } = await seedRun({ status })
|
||||
await pool.query('UPDATE event_runs SET current_phase = ? WHERE id = ?', [phase, runId])
|
||||
const g = await pool.query(GATE_OPEN, [
|
||||
runId,
|
||||
phase,
|
||||
kind,
|
||||
kind === 'after' ? 1800 : null,
|
||||
kind === 'on' ? trigger : null,
|
||||
kind === 'on' ? JSON.stringify({ variable: 'region', cmp: 'eq', value: 'Yew' }) : null,
|
||||
needed,
|
||||
T0,
|
||||
kind === 'after' ? later(1800_000) : null,
|
||||
])
|
||||
return { runId, gateId: Number(g.insertId) }
|
||||
}
|
||||
|
||||
const gateById = async (id) => (await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [id]))[0]
|
||||
|
||||
test('two firings arriving together each count, and exactly one crosses the threshold', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const { gateId } = await seedGate({ needed: 2 })
|
||||
|
||||
// The whole reason the CASE is inside the UPDATE: a read-then-write would let
|
||||
// both of these see "1 of 2" and neither satisfy, or both satisfy and advance
|
||||
// one phase twice.
|
||||
const [a, b] = await Promise.all([
|
||||
pool.query(GATE_COUNT, [T0, null, T0, gateId]),
|
||||
pool.query(GATE_COUNT, [T0, null, T0, gateId]),
|
||||
])
|
||||
assert.equal(rows(a) + rows(b), 2, 'both increments land')
|
||||
|
||||
const gate = await gateById(gateId)
|
||||
assert.equal(Number(gate.tally), 2)
|
||||
assert.ok(gate.satisfied_at, 'and the second one closed it')
|
||||
assert.equal(gate.satisfied_by, 'condition')
|
||||
})
|
||||
|
||||
test('the threshold is read BEFORE the increment, not after it', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The defect this file was written to catch, and did: MariaDB evaluates SET
|
||||
// assignments left to right with the values already assigned, so an increment
|
||||
// placed FIRST makes the CASE after it read `new + 1 >= needed` and close a
|
||||
// two-firing gate on the first firing. Every stub of this agrees with the
|
||||
// intent rather than with the server, which is exactly why it survived one.
|
||||
const { gateId } = await seedGate({ needed: 2 })
|
||||
await pool.query(GATE_COUNT, [T0, null, T0, gateId])
|
||||
const half = await gateById(gateId)
|
||||
assert.equal(Number(half.tally), 1)
|
||||
assert.equal(half.satisfied_at, null, 'one of two is not two')
|
||||
|
||||
await pool.query(GATE_COUNT, [T0, null, T0, gateId])
|
||||
assert.ok((await gateById(gateId)).satisfied_at, 'and the second one is')
|
||||
})
|
||||
|
||||
test('an increment against a closed gate is refused, not applied', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const { gateId } = await seedGate({ needed: 1 })
|
||||
assert.equal(rows(await pool.query(GATE_COUNT, [T0, null, T0, gateId])), 1)
|
||||
|
||||
// `WHERE satisfied_at IS NULL` — and 0 rather than 1, which is the whole
|
||||
// reason this file exists: `foundRows: true` would answer 1 for a no-op.
|
||||
assert.equal(rows(await pool.query(GATE_COUNT, [T0, null, T0, gateId])), 0)
|
||||
assert.equal(Number((await gateById(gateId)).tally), 1, 'the tally does not keep climbing after the phase moved on')
|
||||
})
|
||||
|
||||
test('a force and a deadline race the same guard, and only one of them wins', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const { gateId } = await seedGate({ kind: 'after' })
|
||||
const [forced, elapsed] = await Promise.all([
|
||||
pool.query(GATE_SATISFY, [T0, 'forced', 7, gateId]),
|
||||
pool.query(GATE_SATISFY, [T0, 'elapsed', null, gateId]),
|
||||
])
|
||||
assert.equal(rows(forced) + rows(elapsed), 1, 'exactly one of the two writes')
|
||||
|
||||
const gate = await gateById(gateId)
|
||||
assert.ok(['forced', 'elapsed'].includes(gate.satisfied_by))
|
||||
// Whichever won is the one in the row, and the log records that one — never
|
||||
// both.
|
||||
assert.equal(gate.satisfied_by === 'forced' ? Number(gate.forced_by) : gate.forced_by, gate.satisfied_by === 'forced' ? 7 : null)
|
||||
})
|
||||
|
||||
test('opening a gate twice writes one row', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const { runId } = await seedGate()
|
||||
// The INSERT IGNORE that makes recovery from a process which died between
|
||||
// entering a phase and opening its gate uneventful.
|
||||
const again = await pool.query(GATE_OPEN, [runId, 'boss', 'on', null, 'uo.champ.boss_up', null, 1, later(60_000), null])
|
||||
assert.equal(rows(again), 0)
|
||||
const all = await pool.query('SELECT * FROM event_run_phase_gates WHERE run_id = ?', [runId])
|
||||
assert.equal(all.length, 1)
|
||||
assert.equal(new Date(all[0].entered_at).getTime(), T0.getTime(), 'and the first entry time is the one that stands')
|
||||
})
|
||||
|
||||
test('the emit path sees only gates whose run is live and on that phase', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const live = await seedGate()
|
||||
const paused = await seedGate({ status: 'paused' })
|
||||
const cancelled = await seedGate({ status: 'scheduled' })
|
||||
await pool.query('UPDATE event_runs SET status = ? WHERE id = ?', ['cancelled', cancelled.runId])
|
||||
|
||||
const moved = await seedGate()
|
||||
await pool.query('UPDATE event_runs SET current_phase = ? WHERE id = ?', ['cleanup', moved.runId])
|
||||
|
||||
const closed = await seedGate()
|
||||
await pool.query(GATE_SATISFY, [T0, 'forced', null, closed.gateId])
|
||||
|
||||
const found = (await pool.query(GATE_OPEN_FOR_TRIGGER, ['uo.champ.boss_up'])).map((g) => Number(g.id))
|
||||
assert.deepEqual(found.sort((a, b) => a - b), [live.gateId, paused.gateId].sort((a, b) => a - b))
|
||||
// `paused` counts: the world does not stop because an operator paused the
|
||||
// console, and discarding firings during a pause would make pause destructive.
|
||||
assert.ok(found.includes(paused.gateId))
|
||||
})
|
||||
|
||||
test('a gate goes with its run', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// ON DELETE CASCADE, which is what keeps an orphaned gate from outliving the
|
||||
// run whose phase it was about.
|
||||
const { runId, gateId } = await seedGate()
|
||||
await pool.query('DELETE FROM event_run_steps WHERE run_id = ?', [runId])
|
||||
await pool.query('DELETE FROM event_runs WHERE id = ?', [runId])
|
||||
assert.equal((await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [gateId])).length, 0)
|
||||
})
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
// • a dormant step blocks a PUBLISH and never a SAVE
|
||||
// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)`
|
||||
// would silently collapse them into one at materialisation
|
||||
// • a key a later phase owns (`advance`, `announcements`) is REFUSED rather
|
||||
// than preserved, so no corpus of unvalidated specs accumulates
|
||||
// • a key a later phase owns (`announcements`) is REFUSED rather than
|
||||
// preserved, so no corpus of unvalidated specs accumulates
|
||||
// • a phase's `advance` gate (Phase 5) is checked at SAVE against the
|
||||
// trigger's declaration with the offending variable named, and a gate on an
|
||||
// unregistered trigger is dormant on exactly the rule a step's action is
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
@@ -184,12 +187,166 @@ test('a key a later phase owns is refused, not silently preserved', () => {
|
||||
assert.equal(top.ok, false)
|
||||
assert.match(top.errors.join('\n'), /unknown key "announcements"/)
|
||||
|
||||
// `advance` was one of these until Phase 5 gave it a meaning; the refusal
|
||||
// moved down a level rather than going away, and a key inside a gate that no
|
||||
// shape declares is refused on the same argument.
|
||||
const phase = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m' } }],
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m', timeout: '2h' } }],
|
||||
})
|
||||
assert.equal(phase.ok, false)
|
||||
assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/)
|
||||
assert.match(phase.errors.join('\n'), /unknown key\(s\) timeout for an "after" gate/)
|
||||
})
|
||||
|
||||
// -- The advance gate (Phase 5) --------------------------------------------
|
||||
//
|
||||
// The phase's stated trap: a condition is checked at SAVE against the trigger's
|
||||
// declaration, with the offending variable NAMED. A predicate that silently
|
||||
// reads `undefined` is a phase that silently never advances, and the night you
|
||||
// find out is the night of the event.
|
||||
|
||||
test('an `after` gate normalises its duration the way `days` is normalised', () => {
|
||||
const ok = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '120m' } }],
|
||||
})
|
||||
assert.equal(ok.ok, true)
|
||||
assert.deepEqual(ok.spec.phases[0].advance, { after: '2h' }, 'two spellings of one delay are one spec')
|
||||
assert.equal(spec.parseAfter('2h'), 7200)
|
||||
assert.equal(spec.formatAfter(5400), '90m', 'and a duration with no whole larger unit keeps the smaller one')
|
||||
|
||||
for (const bad of ['0s', '30', '1h30m', 'soon', '0.5h', `${31 * 86_400}s`]) {
|
||||
const result = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: bad } }],
|
||||
})
|
||||
assert.equal(result.ok, false, `"${bad}" should not validate`)
|
||||
assert.match(result.errors.join('\n'), /expected a duration like "30m"/)
|
||||
}
|
||||
})
|
||||
|
||||
test('an `on` gate is validated against the trigger declaration, and names the variable', () => {
|
||||
const bad = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{
|
||||
key: 'main',
|
||||
label: 'Main',
|
||||
steps: [],
|
||||
advance: { on: 'news.post', where: { variable: 'reigon', cmp: 'eq', value: 'Yew' } },
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.equal(bad.ok, false)
|
||||
assert.match(bad.errors.join('\n'), /"reigon" is not a variable of "news.post"/)
|
||||
assert.match(bad.errors.join('\n'), /spec\.phases\[0\]\.advance\.where/, 'and it says which phase')
|
||||
|
||||
// The type check is the grammar's, unchanged: `gt` on a string is refused at
|
||||
// save rather than quietly answering false for ever.
|
||||
const typed = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'main', label: 'Main', steps: [], advance: { on: 'news.post', where: { variable: 'title', cmp: 'gt', value: 'x' } } },
|
||||
],
|
||||
})
|
||||
assert.equal(typed.ok, false)
|
||||
assert.match(typed.errors.join('\n'), /"gt" cannot be applied to a string/)
|
||||
|
||||
const ok = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{
|
||||
key: 'main',
|
||||
label: 'Main',
|
||||
steps: [],
|
||||
advance: { on: 'news.post', where: { variable: 'category', cmp: 'eq', value: 'Five on Friday' }, count: 3 },
|
||||
},
|
||||
],
|
||||
})
|
||||
assert.equal(ok.ok, true)
|
||||
assert.deepEqual(ok.spec.phases[0].advance, {
|
||||
on: 'news.post',
|
||||
where: { variable: 'category', cmp: 'eq', value: 'Five on Friday' },
|
||||
count: 3,
|
||||
dormant: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('a gate on an unregistered trigger is dormant: it saves, and it will not publish', () => {
|
||||
const result = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'main', label: 'Main', steps: [], advance: { on: 'gone.trigger', where: { variable: 'x', cmp: 'eq', value: 1 } } },
|
||||
],
|
||||
})
|
||||
assert.equal(result.ok, true, 'uninstalling a module must not be destructive to an author’s work')
|
||||
assert.equal(result.spec.phases[0].advance.dormant, true)
|
||||
assert.deepEqual(
|
||||
result.spec.phases[0].advance.where,
|
||||
{ variable: 'x', cmp: 'eq', value: 1 },
|
||||
'and the predicate is kept, not deleted for want of a declaration to check it against',
|
||||
)
|
||||
|
||||
const pub = spec.publishable(result.spec)
|
||||
assert.equal(pub.ok, false)
|
||||
assert.deepEqual(pub.dormant, ['gone.trigger'], 'the same list a dormant ACTION lands in, and the same message')
|
||||
})
|
||||
|
||||
test('a gate names exactly one shape, and its count is bounded', () => {
|
||||
const both = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '1h', on: 'news.post' } }],
|
||||
})
|
||||
assert.equal(both.ok, false)
|
||||
assert.match(both.errors.join('\n'), /exactly one of "after" or "on"/)
|
||||
|
||||
const neither = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: {} }],
|
||||
})
|
||||
assert.equal(neither.ok, false)
|
||||
|
||||
for (const count of [0, -1, 1.5, spec.MAX_ADVANCE_COUNT + 1]) {
|
||||
const result = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { on: 'news.post', count } }],
|
||||
})
|
||||
assert.equal(result.ok, false, `count ${count} should not validate`)
|
||||
}
|
||||
})
|
||||
|
||||
test('validate accepts its own output — the walk found this one', () => {
|
||||
// A saved spec is re-validated on every later save and AGAIN AT PUBLISH.
|
||||
// `validate` normalises a gate with `dormant`, and the first draft refused
|
||||
// that key on the way back in: the definition saved, and then publishing it
|
||||
// answered 400 over a field the validator itself had written. The rule was
|
||||
// already on the page for a step's `actionVersion` and `dormant`; the gate
|
||||
// just had to follow it. `validate(validate(x)) === validate(x)`.
|
||||
const once = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'a', label: 'A', steps: [], advance: { on: 'news.post', where: { variable: 'title', cmp: 'present' }, count: 2 } },
|
||||
{ key: 'b', label: 'B', steps: [], advance: { after: '15m' } },
|
||||
],
|
||||
})
|
||||
assert.equal(once.ok, true)
|
||||
const twice = spec.validate(once.spec)
|
||||
assert.equal(twice.ok, true, twice.errors?.join('; '))
|
||||
assert.deepEqual(twice.spec, once.spec)
|
||||
|
||||
// And `dormant` is RECOMPUTED, never trusted: a spec claiming a dead trigger
|
||||
// is fine must not publish just because it says so.
|
||||
const lying = spec.validate({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'a', label: 'A', steps: [], advance: { on: 'gone.trigger', count: 1, dormant: false } }],
|
||||
})
|
||||
assert.equal(lying.spec.phases[0].advance.dormant, true)
|
||||
})
|
||||
|
||||
test('a phase with no gate carries no `advance` key at all', () => {
|
||||
const result = spec.validate({ schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal('advance' in result.spec.phases[0], false, 'so one phase gaining a gate is not a diff on every phase')
|
||||
})
|
||||
|
||||
// ── The schedule shapes (Phase 4) ────────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,7 @@ 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 seriesDb = require('../src/model/events/eventSeries.db')
|
||||
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
@@ -54,6 +55,7 @@ for (const [name, mod] of [
|
||||
['stepsDb', stepsDb],
|
||||
['logDb', logDb],
|
||||
['seriesDb', seriesDb],
|
||||
['gatesDb', gatesDb],
|
||||
['activity', activity],
|
||||
]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
@@ -76,6 +78,7 @@ function installStubs() {
|
||||
steps: new Map(),
|
||||
log: [],
|
||||
series: new Map(),
|
||||
gates: [],
|
||||
occurrences: new Set(),
|
||||
nextDefinition: 1,
|
||||
nextVersion: 1,
|
||||
@@ -250,6 +253,15 @@ function installStubs() {
|
||||
return counts
|
||||
}
|
||||
|
||||
// Phase 5 gave `runs.detail()` a third leg, and an unstubbed one is a real
|
||||
// query against the dead port this file points at: the run-console test hung
|
||||
// for ten seconds and then failed with ECONNREFUSED, saying nothing whatever
|
||||
// about the route. **This is the third time a new leg has caught a stubbing
|
||||
// file out** — Phase 4's expansion leg did it to `eventRunner.test.js`, where
|
||||
// it merely made the file slow. Worth the comment: when the runner or a model
|
||||
// gains a leg, every file that stubs the layer under it needs the stub.
|
||||
gatesDb.listForRun = async (runId) => store.gates.filter((g) => g.run_id === runId)
|
||||
|
||||
logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
|
||||
logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {
|
||||
store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() })
|
||||
|
||||
Reference in New Issue
Block a user