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
595 lines
25 KiB
JavaScript
595 lines
25 KiB
JavaScript
// ── The event spec, and the one place it is validated ──────────────────────
|
|
//
|
|
// EVENTS.md §C ("phases and actions are configuration inside a version snapshot,
|
|
// not tables") and §D. A spec is the authored tree a definition carries and a
|
|
// version freezes: phases, and the steps inside them. It is stored as JSON in
|
|
// `event_definitions`' working copy and in `event_versions.spec`, and this file
|
|
// is the only thing that decides whether one is well formed.
|
|
//
|
|
// **Every check here is a boundary, not a convenience.** The authoring UI (Phase
|
|
// 3, then Phase 13) will re-implement some of them for the sake of a good
|
|
// inline error, and that second copy is expected to drift — so this one is the
|
|
// one that decides. A spec arriving by any other route (a restore, a fixture, a
|
|
// module shipping a definition as content) gets the same answer.
|
|
//
|
|
// **What this file knows, and what it deliberately refuses.** Two top-level keys
|
|
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
|
|
// 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 added a phase's
|
|
// `advance`, Phase 10 adds `announcements`.
|
|
//
|
|
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
|
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
|
|
// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
|
|
// computed in is `event_definitions.timezone`, a sibling column this file cannot
|
|
// see and does not need to: a well-formed wall clock resolves in every zone (a
|
|
// DST gap shifts it, it is never rejected), so a schedule that validates here
|
|
// computes there.
|
|
|
|
const registries = require('../modules/registries')
|
|
const recurrence = require('./recurrence')
|
|
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
|
|
// started". Same grammar as a template key's segment.
|
|
const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
|
|
const MAX_PHASE_KEY = 64
|
|
|
|
// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal
|
|
// with a number in it rather than a run that materialises fifty thousand step
|
|
// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side.
|
|
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.
|
|
const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
|
|
|
|
// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
|
|
// an `at` is an author who believes something about it that is not true — the
|
|
// same argument the top-level refusal makes, one level down.
|
|
const SCHEDULE_KEYS = {
|
|
manual: [],
|
|
once: ['at'],
|
|
weekly: ['days', 'time'],
|
|
monthly: ['nth', 'weekday', 'time'],
|
|
}
|
|
|
|
// What a step does when its attempts are exhausted (§L). The disposition only —
|
|
// retry is not one of the values, it is what happens BEFORE one of them. Each
|
|
// risk class has a default, which is the whole reason `risk` is required at
|
|
// registration: a `change` action that fell back to `skip` would leave a run
|
|
// advancing over a half-changed world.
|
|
const ON_FAILURE = ['skip', 'pause', 'abort_run']
|
|
const ON_FAILURE_BY_RISK = {
|
|
notify: 'skip',
|
|
inspect: 'skip',
|
|
change: 'pause',
|
|
irreversible: 'abort_run',
|
|
}
|
|
|
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
|
|
/** The default disposition for an action whose risk class core knows. */
|
|
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
|
|
|
|
/**
|
|
* Check one schedule shape and answer the normalised form of it.
|
|
*
|
|
* Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
|
|
* one — because `validate` collects every error and carries on, and a caller
|
|
* reading `spec.schedule.days` of a refused spec should find an empty recurrence
|
|
* rather than a half-built one.
|
|
*
|
|
* **`days` is normalised into week order**, not into the order they were typed.
|
|
* The spec is compared, described and diffed, and `['friday','monday']` and
|
|
* `['monday','friday']` naming the same schedule while differing as JSON is a
|
|
* version history that reports edits nobody made.
|
|
*/
|
|
function validateSchedule(kind, raw, errors) {
|
|
const at = (key) => `spec.schedule.${key}`
|
|
|
|
if (kind === 'once') {
|
|
const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
|
|
if (!m) {
|
|
errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
|
|
return { kind: 'manual' }
|
|
}
|
|
const [, y, mo, d, h, mi] = m.map(Number)
|
|
// The regex admits `2026-02-30`, which is a string and not a day.
|
|
if (!recurrence.isRealDate(y, mo, d)) {
|
|
errors.push(`${at('at')}: "${raw.at}" is not a real date`)
|
|
return { kind: 'manual' }
|
|
}
|
|
// Stored as the operator wrote it — a wall clock in the definition's own
|
|
// zone, never a UTC instant. §E: the schedule belongs to the event, and the
|
|
// instant is derived at materialisation.
|
|
const pad = (n) => String(n).padStart(2, '0')
|
|
return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
|
|
}
|
|
|
|
if (kind === 'weekly' || kind === 'monthly') {
|
|
const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
|
|
if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
|
|
|
|
if (kind === 'weekly') {
|
|
const rawDays = Array.isArray(raw.days) ? raw.days : null
|
|
if (!rawDays || rawDays.length === 0) {
|
|
errors.push(`${at('days')}: expected a non-empty array of weekday names`)
|
|
return { kind: 'manual' }
|
|
}
|
|
const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
|
|
if (unknown.length) {
|
|
errors.push(
|
|
`${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
|
|
)
|
|
}
|
|
const days = recurrence.WEEKDAYS.filter((name) =>
|
|
rawDays.some((d) => String(d).toLowerCase() === name),
|
|
)
|
|
if (!time || !days.length) return { kind: 'manual' }
|
|
return { kind: 'weekly', days, time }
|
|
}
|
|
|
|
const weekday = String(raw.weekday ?? '').toLowerCase()
|
|
if (!recurrence.WEEKDAYS.includes(weekday)) {
|
|
errors.push(
|
|
`${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
|
|
)
|
|
}
|
|
const nth = Number(raw.nth)
|
|
if (!recurrence.MONTHLY_NTH.includes(nth)) {
|
|
// -1 is "last", which a month with five Fridays makes different from 4.
|
|
// There is no 5: every month has a first through fourth of every weekday,
|
|
// so the closed set has no absent case (org lead, 2026-09-02).
|
|
errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
|
|
}
|
|
if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
|
|
return { kind: 'manual' }
|
|
}
|
|
return { kind: 'monthly', nth, weekday, time }
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent
|
|
* drop: an author who typed `creatures` where the action declares `creature` has
|
|
* written a step that would dispatch with the count missing, and dropping the key
|
|
* makes that look like it saved cleanly.
|
|
*/
|
|
function checkParams(declaration, raw, path) {
|
|
const errors = []
|
|
const params = {}
|
|
const given = isPlainObject(raw) ? raw : {}
|
|
|
|
if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
|
|
return { params, errors: [`${path}.params: expected an object`] }
|
|
}
|
|
|
|
const declared = new Map((declaration.params || []).map((p) => [p.name, p]))
|
|
for (const name of Object.keys(given)) {
|
|
if (!declared.has(name)) {
|
|
errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`)
|
|
}
|
|
}
|
|
|
|
for (const p of declaration.params || []) {
|
|
const value = given[p.name]
|
|
if (value === undefined || value === null || value === '') {
|
|
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
|
|
continue
|
|
}
|
|
const checked = checkLiteral(p.type, value)
|
|
if (checked.error) {
|
|
errors.push(`${path}.params: "${p.name}" ${checked.error}`)
|
|
continue
|
|
}
|
|
params[p.name] = checked.value
|
|
}
|
|
|
|
return { params, errors }
|
|
}
|
|
|
|
/**
|
|
* Validate and normalise a whole spec.
|
|
*
|
|
* `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }`
|
|
* listing EVERY problem rather than the first — the posture `conditions.validate`
|
|
* takes, and for the same reason: an author fixing one step at a time is an
|
|
* author making six round trips through a form.
|
|
*
|
|
* `knownActionIds` widens what may be named beyond what is registered right now,
|
|
* and it is how a definition survives its module being uninstalled. The rule,
|
|
* lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger:
|
|
* **a step already in the saved spec may keep an unregistered action; a new step
|
|
* may not add one.** Refusing to save the whole definition would make an
|
|
* uninstall destructive after the fact, and silently dropping the step would
|
|
* delete authored work to make a form submit. A kept step is marked
|
|
* `dormant: true` and its params pass through unvalidated, because the only
|
|
* thing that could validate them left with the module.
|
|
*/
|
|
function validate(raw, { knownActionIds = [] } = {}) {
|
|
const errors = []
|
|
const known = new Set(knownActionIds)
|
|
|
|
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
|
|
|
|
const allowed = new Set(['schedule', 'phases'])
|
|
for (const key of Object.keys(raw)) {
|
|
if (!allowed.has(key)) {
|
|
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
|
|
}
|
|
}
|
|
|
|
// ── schedule ──
|
|
const rawSchedule =
|
|
raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
|
|
let schedule = { kind: 'manual' }
|
|
if (!isPlainObject(rawSchedule)) {
|
|
errors.push('spec.schedule: expected an object')
|
|
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
|
|
errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
|
|
} else {
|
|
const kind = rawSchedule.kind
|
|
const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
|
|
const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
|
|
if (extra.length) {
|
|
errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
|
|
}
|
|
schedule = validateSchedule(kind, rawSchedule, errors)
|
|
}
|
|
|
|
// ── phases ──
|
|
const rawPhases = raw.phases
|
|
const phases = []
|
|
if (!Array.isArray(rawPhases) || rawPhases.length === 0) {
|
|
errors.push('spec.phases: expected a non-empty array')
|
|
return { ok: false, errors }
|
|
}
|
|
if (rawPhases.length > MAX_PHASES) {
|
|
errors.push(`spec.phases: at most ${MAX_PHASES} phases`)
|
|
return { ok: false, errors }
|
|
}
|
|
|
|
const seenKeys = new Set()
|
|
let totalSteps = 0
|
|
|
|
rawPhases.forEach((rawPhase, pi) => {
|
|
const path = `spec.phases[${pi}]`
|
|
if (!isPlainObject(rawPhase)) {
|
|
errors.push(`${path}: expected an object`)
|
|
return
|
|
}
|
|
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps', 'advance'].includes(k))
|
|
if (extra.length) {
|
|
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}"`)
|
|
} else if (seenKeys.has(key)) {
|
|
// Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two
|
|
// phases sharing a key would silently collapse into one at materialisation
|
|
// and half the authored steps would never exist.
|
|
errors.push(`${path}.key: "${key}" is used by more than one phase`)
|
|
} else {
|
|
seenKeys.add(key)
|
|
}
|
|
|
|
if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`)
|
|
|
|
const rawSteps = rawPhase.steps
|
|
if (!Array.isArray(rawSteps)) {
|
|
errors.push(`${path}.steps: expected an array`)
|
|
return
|
|
}
|
|
if (rawSteps.length > MAX_STEPS_PER_PHASE) {
|
|
errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`)
|
|
return
|
|
}
|
|
totalSteps += rawSteps.length
|
|
|
|
const steps = []
|
|
rawSteps.forEach((rawStep, si) => {
|
|
const spath = `${path}.steps[${si}]`
|
|
if (!isPlainObject(rawStep)) {
|
|
errors.push(`${spath}: expected an object`)
|
|
return
|
|
}
|
|
// `actionVersion` and `dormant` are in this list because **validate must
|
|
// accept its own output**. A saved spec is re-validated on every later
|
|
// save and again at publish, so a normalised field that the validator
|
|
// itself added and then refused would make the second save of any
|
|
// definition impossible. They are accepted and then RECOMPUTED below
|
|
// rather than trusted: the version comes from the declaration, and
|
|
// dormancy from whether anyone registers the action right now.
|
|
const stepExtra = Object.keys(rawStep).filter(
|
|
(k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k),
|
|
)
|
|
if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`)
|
|
|
|
const actionId = rawStep.actionId
|
|
const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null
|
|
|
|
if (typeof actionId !== 'string' || !actionId) {
|
|
errors.push(`${spath}.actionId: a step needs an action`)
|
|
return
|
|
}
|
|
if (!declaration && !known.has(actionId)) {
|
|
errors.push(`${spath}.actionId: no module registers "${actionId}"`)
|
|
return
|
|
}
|
|
|
|
if (!declaration) {
|
|
// Dormant: kept verbatim, params untouched, and flagged so the editor and
|
|
// the run console can both say WHY rather than showing an empty step.
|
|
steps.push({
|
|
actionId,
|
|
label: rawStep.label || actionId,
|
|
params: isPlainObject(rawStep.params) ? rawStep.params : {},
|
|
actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1,
|
|
onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause',
|
|
dormant: true,
|
|
})
|
|
return
|
|
}
|
|
|
|
const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
|
|
errors.push(...paramErrors)
|
|
|
|
if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
|
|
errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
|
|
}
|
|
|
|
steps.push({
|
|
actionId,
|
|
label: rawStep.label || declaration.label,
|
|
params,
|
|
// Captured at SAVE time, from the declaration this step was authored
|
|
// against (§F). It is what lets a later bump render a warning in the
|
|
// editor instead of dispatching a mistyped parameter.
|
|
actionVersion: declaration.version,
|
|
onFailure: ON_FAILURE.includes(rawStep.onFailure)
|
|
? rawStep.onFailure
|
|
: defaultOnFailure(declaration.risk),
|
|
dormant: false,
|
|
})
|
|
})
|
|
|
|
// `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`)
|
|
|
|
if (errors.length) return { ok: false, errors }
|
|
return { ok: true, spec: { schedule, phases } }
|
|
}
|
|
|
|
/** Every action id a spec names, dormant ones included. */
|
|
const actionIdsIn = (spec) =>
|
|
(spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean)
|
|
|
|
/**
|
|
* A spec is publishable when nothing in it is dormant.
|
|
*
|
|
* Separate from `validate` on purpose: a dormant step must not stop an author
|
|
* 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 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: [] }
|
|
}
|
|
|
|
/** An empty, valid spec — what a newly created draft carries. */
|
|
const emptySpec = () => ({
|
|
schedule: { kind: 'manual' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
|
})
|
|
|
|
module.exports = {
|
|
validate,
|
|
publishable,
|
|
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,
|
|
MAX_STEPS_PER_PHASE,
|
|
MAX_STEPS,
|
|
}
|