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

@@ -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 })
})

View File

@@ -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 () => {

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')
})

View File

@@ -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)
})

View File

@@ -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 authors 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) ────────────────────────────────────

View File

@@ -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() })