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

@@ -17,7 +17,7 @@
// than preserved: a spec that silently carries `announcements` today is a spec
// whose author believes announcements work, and the later phase that gives the
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
// changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
// `advance`, Phase 10 adds `announcements`.
//
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
@@ -30,7 +30,8 @@
const registries = require('../modules/registries')
const recurrence = require('./recurrence')
const { checkLiteral } = require('../engagement/conditions')
const conditionGrammar = require('../engagement/conditions')
const { checkLiteral } = conditionGrammar
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
// run console groups by, and it is what an operator reads in "phase 3 has not
@@ -45,6 +46,31 @@ const MAX_PHASES = 40
const MAX_STEPS_PER_PHASE = 100
const MAX_STEPS = 500
// The two shapes a phase's `advance` may take (§E). There is deliberately no
// third: a gate that never opens is held, made visible and left to an operator
// (org lead, 2026-09-02), so there is no authored timeout and no disposition to
// validate. Adding one later is one key and one branch, and this is the list a
// reader should find it missing from.
const ADVANCE_KINDS = ['after', 'on']
// `after: '30m'` — one integer and one unit, and nothing else. No `1h30m`, no
// fractions: the whole reason a duration is a string here rather than the plain
// integer seconds `core.wait` takes is that an operator proofreads it, and a
// grammar that admits two spellings of ninety minutes is one an operator has to
// parse rather than read.
const AFTER_RE = /^(\d{1,6})(s|m|h|d)$/
const AFTER_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 }
const MIN_AFTER_SECONDS = 1
// A paste guard rather than a policy, in the spirit of MAX_PHASES: thirty days
// is longer than any event this system is for, and a phase gate of ten years is
// a typo that would otherwise hold a run — and its concurrency key — for ever.
const MAX_AFTER_SECONDS = 30 * 86_400
// How many firings one `on` gate may wait for. Bounded for the reason MAX_LIST
// is: it is authored into a JSON column, and "count: 100000" is a phase that
// never advances written as one that eventually does.
const MAX_ADVANCE_COUNT = 1000
// The four closed shapes of §E. `manual` is first because it is the default and
// what an unscheduled draft carries; the other three are recurrences the runner
// expands into occurrences ahead of time.
@@ -158,6 +184,140 @@ function validateSchedule(kind, raw, errors) {
return { kind: 'manual' }
}
/**
* Parse `'30m'` into seconds, or answer null.
*
* Exported because the run console renders the same duration back and must not
* grow a second opinion about what `'2h'` means.
*/
function parseAfter(raw) {
const m = AFTER_RE.exec(String(raw ?? ''))
if (!m) return null
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
if (seconds < MIN_AFTER_SECONDS || seconds > MAX_AFTER_SECONDS) return null
return seconds
}
/** Seconds back to the largest whole unit that expresses them exactly. */
function formatAfter(seconds) {
for (const unit of ['d', 'h', 'm']) {
const size = AFTER_UNIT_SECONDS[unit]
if (seconds % size === 0) return `${seconds / size}${unit}`
}
return `${seconds}s`
}
/**
* Check a phase's `advance` gate and answer the normalised form of it.
*
* Returns `null` for a phase with no gate — the common case, and the behaviour
* every phase had before Phase 5: it advances when its steps go terminal and on
* nothing else. A gate is an ADDITIONAL condition, never a replacement, so a
* phase whose steps are still running is not advanced by a satisfied gate.
*
* **The duration is normalised the way `days` is** — `'120m'` is stored as
* `'2h'` — because the spec is diffed between versions, and two spellings of one
* delay differing as JSON is a version history that reports edits nobody made.
*
* **`where` is validated against the trigger's DECLARATION, at save, with the
* offending variable named.** This is the whole trap of this phase, and it is
* `engagement/conditions.js`'s own argument one system across: 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.
*
* **A trigger nobody registers makes the gate DORMANT, not invalid.** Same rule
* as a step naming an action no installed module declares: it saves, so
* uninstalling a module is not destructive to an author's work, and it refuses
* to publish, because a version runs are pinned to must not wait on a trigger
* that can never fire.
*/
function validateAdvance(raw, path, errors) {
if (raw === undefined || raw === null) return null
if (!isPlainObject(raw)) {
errors.push(`${path}: expected an object`)
return null
}
const keys = Object.keys(raw)
const named = ADVANCE_KINDS.filter((k) => keys.includes(k))
if (named.length !== 1) {
errors.push(`${path}: expected exactly one of "after" or "on"`)
return null
}
const kind = named[0]
if (kind === 'after') {
const extra = keys.filter((k) => k !== 'after')
if (extra.length) {
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "after" gate`)
return null
}
const seconds = parseAfter(raw.after)
if (seconds === null) {
errors.push(
`${path}.after: expected a duration like "30m" — a whole number of s, m, h or d, ` +
`between ${MIN_AFTER_SECONDS}s and ${formatAfter(MAX_AFTER_SECONDS)}`,
)
return null
}
// Only the canonical string is stored. The seconds are re-derived by the one
// caller that needs them (the runner, when it opens the gate) through the
// exported `parseAfter`, rather than kept beside it as a second field two
// versions of the spec could disagree about.
return { after: formatAfter(seconds) }
}
// `dormant` is in this list for the reason `actionVersion` and `dormant` are
// in a step's — **validate must accept its own output.** A saved spec is
// re-validated on every later save and again at publish, so a field the
// validator itself added and then refused would make the second save of any
// gated definition impossible. It is accepted and then RECOMPUTED below,
// never trusted: dormancy is whether anybody registers that trigger right
// now, not what was true when the spec was last written.
const extra = keys.filter((k) => !['on', 'where', 'count', 'dormant'].includes(k))
if (extra.length) {
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "on" gate`)
return null
}
const triggerId = raw.on
if (typeof triggerId !== 'string' || !triggerId) {
errors.push(`${path}.on: expected a trigger id`)
return null
}
let count = 1
if (raw.count !== undefined && raw.count !== null) {
if (!Number.isInteger(raw.count) || raw.count < 1 || raw.count > MAX_ADVANCE_COUNT) {
errors.push(`${path}.count: expected a whole number between 1 and ${MAX_ADVANCE_COUNT}`)
return null
}
count = raw.count
}
const declaration = registries.eventTrigger(triggerId)
if (!declaration) {
// Dormant, exactly as an unregistered action is. `where` is carried through
// unvalidated and unnormalised — there is no declaration to check it
// against, and dropping it would silently delete an author's predicate the
// moment a module was uninstalled.
return { on: triggerId, where: raw.where ?? null, count, dormant: true }
}
const checked = conditionGrammar.validate(declaration, raw.where ?? null)
if (!checked.ok) {
// The grammar paths its own errors from the root token `conditions`; this
// re-roots them at the phase so an author reading five of them at once can
// tell which phase each belongs to. The text after the path — the part that
// names the variable — is the grammar's, unchanged.
checked.errors.forEach((e) =>
errors.push(`${path}.where${e.startsWith('conditions') ? e.slice('conditions'.length) : `: ${e}`}`),
)
return null
}
return { on: triggerId, where: checked.conditions, count, dormant: false }
}
/**
* Check one authored param object against an action's declared params.
*
@@ -269,11 +429,13 @@ function validate(raw, { knownActionIds = [] } = {}) {
errors.push(`${path}: expected an object`)
return
}
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps'].includes(k))
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps', 'advance'].includes(k))
if (extra.length) {
errors.push(`${path}: unknown key(s) ${extra.join(', ')} (a phase gains "advance" in Phase 5)`)
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
}
const advance = validateAdvance(rawPhase.advance, `${path}.advance`, errors)
const key = rawPhase.key
if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
errors.push(`${path}.key: bad phase key "${key}"`)
@@ -366,7 +528,11 @@ function validate(raw, { knownActionIds = [] } = {}) {
})
})
phases.push({ key, label: rawPhase.label, steps })
// `advance` is omitted rather than written as null when there is no gate:
// the overwhelming majority of phases have none, and a spec full of
// `"advance": null` is a diff between two versions that says something
// changed about every phase the first time one phase gained a gate.
phases.push(advance ? { key, label: rawPhase.label, steps, advance } : { key, label: rawPhase.label, steps })
})
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
@@ -386,10 +552,18 @@ const actionIdsIn = (spec) =>
* SAVING (that is what makes an uninstall non-destructive), and it must stop
* them PUBLISHING, because publishing is what makes a version a thing runs are
* pinned to and a run cannot dispatch a verb nobody registers.
*
* **A phase's advance gate is dormant on the same rule** (Phase 5), and it is in
* the same list because it fails for the same reason and the message already
* reads correctly for both: a version that waits on a trigger nothing can emit
* is a run that would never leave that phase.
*/
function publishable(spec) {
const dormant = (spec?.phases || [])
.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId))
const phases = spec?.phases || []
const dormant = [
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
]
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
}
@@ -405,8 +579,13 @@ module.exports = {
actionIdsIn,
emptySpec,
defaultOnFailure,
parseAfter,
formatAfter,
PHASE_KEY,
SCHEDULE_KINDS,
ADVANCE_KINDS,
MAX_ADVANCE_COUNT,
MAX_AFTER_SECONDS,
ON_FAILURE,
ON_FAILURE_BY_RISK,
MAX_PHASES,