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:
244
server/src/events/gates.js
Normal file
244
server/src/events/gates.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// ── Phase advance gates: the emit-path observer, and the words for the panel ─
|
||||
//
|
||||
// EVENTS.md §E and § Observability, and Phase 5 of EVENTS_PLAN.md. Two things
|
||||
// live here because they are two halves of one claim — that an operator can
|
||||
// answer *"why didn't phase 3 start?"* without reading a server log:
|
||||
//
|
||||
// `observe(event)` — the trigger stream's other subscriber. Beside
|
||||
// `engine.dispatch`, on the same seam, with the same
|
||||
// fire-and-forget posture.
|
||||
// `describe(gate)` — the same gate rendered in the condition builder's own
|
||||
// words, which is what the diagnosis panel shows.
|
||||
//
|
||||
// **Why the counting happens here and not on the runner's tick.** A gate that
|
||||
// waits for three boss spawns is counting things that happen *between* ticks. A
|
||||
// poller cannot count them: fifteen seconds after the third spawn there is
|
||||
// nothing left to observe, and a tally kept in a process's memory is a tally a
|
||||
// restart silently sets back to zero — with the phase then waiting for three
|
||||
// more of something that already happened. So the emit path writes, and the tick
|
||||
// reads. That division is the whole design of this file.
|
||||
//
|
||||
// **The cost of that, and its bound.** Every game event of every trigger some
|
||||
// run is waiting on costs one indexed lookup, and the common answer is zero
|
||||
// rows. Only when a gate is open does anything else happen, and then it is one
|
||||
// UPDATE per open gate — bounded by how many runs can be waiting on one trigger
|
||||
// at once, which is bounded by how many runs exist.
|
||||
//
|
||||
// **The words are rendered here, on the server, not in the client.** The panel's
|
||||
// entire value is that it reads the way the condition builder reads — `gte` as
|
||||
// *"is at least"*, `present` as *"is present"* — and those labels are defined in
|
||||
// `engagement/conditions.js`. A renderer in the browser would be a second
|
||||
// implementation of a grammar the server owns, and the first operator to meet a
|
||||
// clause it spelled differently would be the operator diagnosing a stalled run
|
||||
// at two in the morning.
|
||||
|
||||
const gatesDb = require('../model/events/eventPhaseGates.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const conditions = require('../engagement/conditions')
|
||||
const log = require('../utils/logger')('event-gates')
|
||||
|
||||
// How long an `on` gate may wait before the run is called `stalled` (§E's third
|
||||
// health value, which nothing had ever written before this phase). It is a
|
||||
// VISIBILITY threshold and not a timeout: nothing advances, nothing fails, and
|
||||
// the operator decides. An hour is long enough that a champion spawn nobody has
|
||||
// killed yet is not an alarm, and short enough that a run which will wait for
|
||||
// ever is on the screen inside one shift.
|
||||
//
|
||||
// It does not apply to an `after` gate. A phase waiting out six hours it was
|
||||
// authored to wait is not stalled, it is working, and health that said otherwise
|
||||
// would train an operator to ignore it.
|
||||
const STALL_MS = Number(process.env.EVENT_PHASE_STALL_MS) || 60 * 60 * 1000
|
||||
|
||||
/** Every variable a condition tree names, in the order it names them. */
|
||||
function variablesIn(node, out = []) {
|
||||
if (!node || typeof node !== 'object') return out
|
||||
if (Array.isArray(node.nodes)) {
|
||||
node.nodes.forEach((child) => variablesIn(child, out))
|
||||
return out
|
||||
}
|
||||
if (node.variable && !out.includes(node.variable)) out.push(node.variable)
|
||||
return out
|
||||
}
|
||||
|
||||
const quote = (v) => (typeof v === 'string' ? `"${v}"` : String(v))
|
||||
|
||||
/**
|
||||
* One condition tree as a sentence, using the grammar's own operator labels.
|
||||
*
|
||||
* `null` answers null rather than "always": the caller renders *"on any
|
||||
* `uo.champ.boss_up`"* for a gate with no predicate, and a phrase saying
|
||||
* "everything is true" would be a clause an operator has to read past.
|
||||
*/
|
||||
function phrase(node) {
|
||||
if (!node || typeof node !== 'object') return null
|
||||
if (node.op === 'not') {
|
||||
const inner = phrase((node.nodes || [])[0])
|
||||
return inner ? `not (${inner})` : null
|
||||
}
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const parts = (node.nodes || []).map(phrase).filter(Boolean)
|
||||
if (!parts.length) return null
|
||||
// Parenthesised only where it changes the reading. A flat `and` of three
|
||||
// comparisons is a sentence; the same three wrapped in brackets is a
|
||||
// diagnosis an operator has to parse rather than read.
|
||||
const joined = parts.map((p) => (p.includes(' or ') || p.includes(' and ') ? `(${p})` : p))
|
||||
return joined.join(node.op === 'and' ? ' and ' : ' or ')
|
||||
}
|
||||
const operator = conditions.OPERATORS[node.cmp]
|
||||
if (!operator) return null
|
||||
if (operator.arity === 0) return `${node.variable} ${operator.label}`
|
||||
if (operator.arity === 'list') return `${node.variable} ${operator.label} ${node.value.map(quote).join(', ')}`
|
||||
return `${node.variable} ${operator.label} ${quote(node.value)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the run console renders — a gate as an operator reads it.
|
||||
*
|
||||
* Derived rather than stored, every field of it. `stalled` in particular is a
|
||||
* comparison against the clock and not a column: a threshold that had been
|
||||
* written into a row at entry could not be changed by an operator raising
|
||||
* `EVENT_PHASE_STALL_MS`, and one written at the moment of stalling would be a
|
||||
* fourth writer on a row two already share.
|
||||
*/
|
||||
function describe(gate, now = new Date()) {
|
||||
if (!gate) return null
|
||||
const since = new Date(gate.entered_at)
|
||||
const satisfied = Boolean(gate.satisfied_at)
|
||||
// **A satisfied gate's clock stops when it was satisfied**, not at read time.
|
||||
// Live it 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 caught it disagreeing with `phase.advanced`'s
|
||||
// own `waitedSeconds` by the age of the screen — 139s beside a logged 121.
|
||||
const until = satisfied ? new Date(gate.satisfied_at) : now
|
||||
const elapsedSeconds = Math.max(0, Math.round((until.getTime() - since.getTime()) / 1000))
|
||||
|
||||
return {
|
||||
phase: gate.phase,
|
||||
kind: gate.kind,
|
||||
satisfied,
|
||||
satisfiedAt: gate.satisfied_at || null,
|
||||
satisfiedBy: gate.satisfied_by || null,
|
||||
since: gate.entered_at,
|
||||
elapsedSeconds,
|
||||
// An `after` gate is never stalled; an `on` gate is stalled once it has
|
||||
// waited past the threshold and not before.
|
||||
stalled: !satisfied && gate.kind === 'on' && now.getTime() - since.getTime() >= STALL_MS,
|
||||
...(gate.kind === 'after'
|
||||
? { after: gate.after_seconds, dueAt: gate.due_at }
|
||||
: {
|
||||
waitingOn: gate.trigger_id,
|
||||
where: phrase(gate.conditions),
|
||||
seen: gate.tally,
|
||||
needed: gate.needed,
|
||||
lastEvent: gate.last_event,
|
||||
lastEventAt: gate.last_event_at || null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger stream's second subscriber.
|
||||
*
|
||||
* Called from `engagementEmit.emit` beside `engine.dispatch` and, like it, never
|
||||
* awaited and never allowed to reject. An emit is a module saying something
|
||||
* happened in the game; whether some event run cared is core's business, and a
|
||||
* failure of core's must not become the module's control flow.
|
||||
*
|
||||
* **Every firing is logged, matched or not** (§ Observability: "trigger
|
||||
* evaluations that did and did not satisfy a condition"). The near miss is the
|
||||
* more valuable of the two on the night: *"the boss did spawn, in Britain"* and
|
||||
* *"no boss has spawned"* are different answers, and without this line they look
|
||||
* identical on the screen.
|
||||
*/
|
||||
async function observe(event) {
|
||||
const summary = { gates: 0, counted: 0, satisfied: 0 }
|
||||
try {
|
||||
const open = await gatesDb.openForTrigger(event.triggerId)
|
||||
summary.gates = open.length
|
||||
if (!open.length) return summary
|
||||
|
||||
const now = new Date()
|
||||
for (const gate of open) {
|
||||
const matched = conditions.evaluate(gate.conditions, event.data || {})
|
||||
|
||||
// ONLY the variables the condition names, never the payload. This 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. The named variables are also the useful ones: they are the
|
||||
// reason it did or did not count.
|
||||
const named = variablesIn(gate.conditions)
|
||||
const lastEvent = {
|
||||
trigger: event.triggerId,
|
||||
at: event.occurredAt,
|
||||
subject: event.subject ?? null,
|
||||
matched,
|
||||
variables: Object.fromEntries(
|
||||
named.filter((n) => event.data && n in event.data).map((n) => [n, event.data[n]]),
|
||||
),
|
||||
}
|
||||
|
||||
const result = matched
|
||||
? await gatesDb.count(gate.id, { lastEvent, now })
|
||||
: { counted: false, satisfied: false, tally: gate.tally, near: await gatesDb.noteNearMiss(gate.id, { lastEvent, now }) }
|
||||
|
||||
if (matched && result.counted) summary.counted += 1
|
||||
if (result.satisfied) summary.satisfied += 1
|
||||
|
||||
await logDb.write({
|
||||
runId: gate.run_id,
|
||||
kind: 'condition.evaluated',
|
||||
phase: gate.phase,
|
||||
detail: {
|
||||
trigger: event.triggerId,
|
||||
matched,
|
||||
// The tally the DATABASE holds after the write, not the one this
|
||||
// process predicted — two emits arriving together each read the same
|
||||
// stale number, and only one of them is right about what it became.
|
||||
seen: result.tally ?? gate.tally,
|
||||
needed: gate.needed,
|
||||
satisfied: result.satisfied,
|
||||
variables: lastEvent.variables,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.counted || summary.satisfied) {
|
||||
log.info('phase gate advanced', { trigger: event.triggerId, ...summary })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('gate observation failed', { trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this phase's gate open — and if it is not, why not?
|
||||
*
|
||||
* The runner's question, and the one place an `after` gate is closed: its
|
||||
* deadline passing is not an event anything emits, so the tick that finds it
|
||||
* past `due_at` is what records it. Doing that here rather than in the runner
|
||||
* keeps `satisfied_by` a fact one file writes.
|
||||
*
|
||||
* Answers `{ open, gate }`. `open: true` with a null gate is a phase with no
|
||||
* advance condition at all — every phase before this one, and most after it.
|
||||
*/
|
||||
async function check(runId, phase, now = new Date()) {
|
||||
const gate = await gatesDb.forPhase(runId, phase)
|
||||
if (!gate) return { open: true, gate: null }
|
||||
if (gate.satisfied_at) return { open: true, gate }
|
||||
|
||||
if (gate.kind === 'after' && gate.due_at && new Date(gate.due_at) <= now) {
|
||||
if (await gatesDb.satisfy(gate.id, 'elapsed', { now })) {
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
// Somebody else closed it between the read and the write — a force, or the
|
||||
// tick that overran into this one. Either way it is open, and whichever
|
||||
// reason won is the one in the row.
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
|
||||
return { open: false, gate }
|
||||
}
|
||||
|
||||
module.exports = { observe, check, describe, phrase, variablesIn, STALL_MS }
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user