// ── What the three Events screens say, and what they let staff press ─────── // // EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server // decides what may be saved, and the six control statements decide what may // happen to a run — every one of them is a compare-and-set that re-checks the // status this file only *predicted*. What is here is the part that would be // wrong silently: a form that drops an authored step, a params box that posts a // string where the action declared an int, and above all a console that offers a // button the server is going to refuse. // // **The controls are modelled here rather than inline in the console for one // reason: they can be tested against the server's rules.** A button that 409s is // not a bug the way a wrong write is, but it is the failure mode an operator // meets at 2am while the thing they are trying to stop keeps running — so the // guards are written twice on purpose and the copy is checked. // **The condition builder is borrowed, not rebuilt.** §I says the step editor // reuses "the condition builder, exactly" — and a phase's advance gate is // literally the engagement grammar, validated on the server by // `engagement/conditions.js`. Importing the row helpers is what keeps this screen // from becoming a second opinion about a grammar core owns. import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js' // A run that is over. Verbatim `eventRuns.db`'s TERMINAL. export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed'] export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status) /** A step waiting on a human: `running`, with nothing holding it. */ export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked) /** * The highest `seq` of a step in this phase that is not still `pending` — the * furthest the phase has got — or null when none of it has been attempted. * * The same rule as the server's `lastStartedSeq`, over the step list the console * already has, and used only to decide whether to OFFER retry. The near miss is * worth keeping in view: "the lowest step that is not finished" looks like the * same thing and is not, because the runner steps OVER a failed step. Under that * rule a phase that carried on past an `on_failure: skip` failure and then paused * at a later one would offer retry on the wrong step. */ export function lastStartedSeqOf(steps, phase) { const started = (steps || []) .filter((s) => s.phase === phase && s.status !== 'pending') .map((s) => Number(s.seq)) return started.length ? Math.max(...started) : null } /** * Which run-level controls to offer. * * `pause` is `starting`/`running` only: a `scheduled` occurrence that should not * happen is cancelled, not paused. `cancel` is everything non-terminal — "this * is not happening" is a decision made before a run starts as often as during * one. * * **`advance` is offered only when the phase is genuinely waiting on its gate**, * which is the same test the server makes and is stated here in the same words * on purpose: this decides what is *offered*, the server decides what is * *allowed*, and a button that is present and always refused is the "control * that answers 409 and does nothing" this feature has refused twice. The gate * must be open-and-unsatisfied AND no step of the phase may still be pending or * running — a phase held by a step is held by the step, and skip is its control. */ export function runControlsFor(run, gates = [], steps = []) { if (!run) return { pause: false, resume: false, cancel: false, advance: false } const terminal = isTerminalRun(run.status) const gate = (gates || []).find((g) => g.phase === run.currentPhase) const stepOpen = (steps || []).some( (s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status), ) return { pause: ['starting', 'running'].includes(run.status), resume: run.status === 'paused', cancel: !terminal, advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen, } } /** * Which step-level controls to offer, for one step of one run. * * `retry` carries the guard worth restating: only while the run is PAUSED, only * on a `failed` step of the phase the run is currently in, and only when that * step is the furthest one the phase has reached. A failed step under an * `on_failure` of `skip` is one the run has already moved past, and re-queueing * it would put a pending row behind the runner's cursor, where it would sit for * ever. */ export function stepControlsFor(run, step, steps) { const none = { confirm: false, skip: false, retry: false } if (!run || !step) return none if (isTerminalRun(run.status)) return none const parked = isParked(step) const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null return { confirm: parked, skip: parked || step.status === 'pending', retry: run.status === 'paused' && step.status === 'failed' && step.phase === run.currentPhase && furthest !== null && Number(furthest) === Number(step.seq), } } // ── The definition form ──────────────────────────────────────────────────── export const BLANK_PHASE_KEY = 'phase' const nextPhaseKey = (phases) => { const used = new Set((phases || []).map((p) => p.key)) for (let n = 1; n < 100; n++) { const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}` if (!used.has(key)) return key } return `${BLANK_PHASE_KEY}-${Date.now()}` } /** * A new step, with its params PREFILLED from the action's declared examples. * * Every param carries a required `example` — that requirement is the reason this * works — so a fresh `core.announce` step arrives with the right keys and * plausible values rather than empty. Phase 13 turned the box into a form and * this stayed exactly as it was: a form whose fields start at the declared * example is a step an author edits rather than one they compose. */ export function blankStep(action) { const params = {} for (const p of action?.params || []) { if (p.required || p.example !== undefined) params[p.name] = p.example } return { actionId: action?.id || '', label: action?.label || '', onFailure: '', paramsText: JSON.stringify(params, null, 2), } } export function blankPhase(phases) { return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() } } /** * The advance gate as the FORM holds it (Phase 5) — three fields that are * always present and mostly empty, rather than a discriminated union the form * has to rebuild every time the dropdown moves. * * `kind: ''` is "no condition", which is what nearly every phase is and what * every phase was before this. The form keeps a half-typed `on` gate's trigger * while the author looks at `after`, because a dropdown that discards what was * typed under the other option is one an operator learns to be afraid of. */ export function blankAdvance() { return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() } } /** * The `where` predicate as the BUILDER holds it (Phase 13). * * `whereText` survives beside the rows and is not vestigial: it is what a * predicate the builder cannot render is shown as, and what is posted for one. * See `whereFormFrom`. */ export function blankWhere() { return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' } } export const ADVANCE_KINDS = [ { value: '', label: 'When its steps are done' }, { value: 'after', label: 'After a fixed delay' }, { value: 'on', label: 'When something happens in the game' }, ] /** The stored gate, as the form's fields. */ export function advanceFormFrom(advance) { const blank = blankAdvance() if (!advance) return blank if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after } return { ...blank, kind: 'on', on: advance.on || '', count: advance.count ?? 1, ...whereFormFrom(advance.where), } } /** * A stored `where` tree → the builder's flat rows (Phase 13). * * **This is `conditionRowsFrom` and it is deliberately the same function**, not a * second one shaped like it. The grammar behind a phase gate is the engagement * condition grammar — the server validates it with `engagement/conditions.js` * and renders the diagnosis panel's sentence with the same labels — so an editor * here that re-decided what a tree looks like would be the second implementation * §I refuses on the read side for exactly this reason. * * A tree the flat editor cannot hold (`A and (B or C)`) comes back * `whereEditable: false` and is SHOWN as its JSON rather than silently * flattened: `A and B and C` fires on different events, and an author would have * no way to know the save had done it to them. */ export function whereFormFrom(where) { const blank = blankWhere() if (!where) return blank const rows = conditionRowsFrom(where) return { whereOp: rows.op, whereRows: rows.rows, whereEditable: rows.editable, whereText: JSON.stringify(where, null, 2), } } /** The editor's working state, from what `GET /admin/events/:id` returned. */ export function formFromDefinition(event) { const spec = event?.spec || {} return { title: event?.title || '', summary: event?.summary || '', body: event?.body || '', imageUrl: event?.imageUrl || '', seriesId: event?.seriesId ? String(event.seriesId) : '', seriesOrder: event?.seriesOrder ?? 0, concurrencyKey: event?.concurrencyKey || '', graceSeconds: event?.graceSeconds ?? 900, timezone: event?.timezone || 'UTC', // Whether the public calendar announces it (Phase 14a). `?? true` rather // than `|| true`: a definition an operator has deliberately unlisted sends // `false`, and `||` would quietly re-list it on the next save. listed: event?.listed ?? true, // Whether the public calendar announces it (Phase 14a). `?? true` rather // than `|| true`: a definition an operator has deliberately unlisted sends // `false`, and `||` would quietly re-list it on the next save. listed: event?.listed ?? true, ...scheduleFormFrom(spec.schedule), phases: (spec.phases || []).map((p) => ({ key: p.key || '', label: p.label || '', advance: advanceFormFrom(p.advance), steps: (p.steps || []).map((s) => ({ actionId: s.actionId || '', label: s.label || '', onFailure: s.onFailure || '', dormant: Boolean(s.dormant), actionVersion: s.actionVersion, paramsText: JSON.stringify(s.params || {}, null, 2), })), })), } } /** * One phase's advance gate, as the spec shape — or null when it has none. * * **Whether the predicate is VALID is still the server's answer.** The builder * coerces each literal to the type the trigger DECLARED — which is not a second * validator but the thing that makes the first one's error useful: every value * in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int` * variable is refused by `engagement/conditions.js`, rightly, at which point the * author is reading an error about JSON rather than about what they typed. * * A predicate the builder could not render round-trips through `whereText` * unchanged. That is the point of keeping the text: the alternative to posting it * back verbatim is dropping an author's tree because this screen could not draw * it. */ export function advancePayload(advance, where, errors, variables = []) { if (!advance || !advance.kind) return null if (advance.kind === 'after') return { after: advance.after } const out = { on: advance.on, count: Number(advance.count) || 1 } if (advance.whereEditable === false) { const text = String(advance.whereText || '').trim() if (text) { try { out.where = JSON.parse(text) } catch (err) { errors.push(`${where}, advance condition: ${err.message}`) } } return out } const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables) if (built) out.where = built return out } /** * The form, as a request body — or the list of everything wrong with it. * * Only the JSON parse is checked here, and only because a params box whose text * is not JSON cannot be turned into a request at all. **Everything else is left * to the server**: unknown params, wrong types, missing required ones, bad phase * keys and duplicate keys all come back from `POST`/`PUT` as a list, and * re-deciding any of them here would be a second validator drifting from the one * that matters. * * `onFailure` is omitted when the author has not chosen one, so the server * applies the action's risk-class default rather than being told a value the * form invented. */ export function payloadFromForm(form, { triggersById = new Map() } = {}) { const errors = [] const phases = (form.phases || []).map((phase, pi) => { const where = advancePayload( phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors, // The declared types the builder coerces against. A trigger nothing // registers has none, and every literal then stays the string it was typed // as — which is right: the gate is dormant, the server carries its `where` // through unvalidated, and inventing types for it here would edit a // predicate nobody can currently check. triggersById.get(phase.advance?.on)?.variables || [], ) return { key: phase.key, label: phase.label, // Omitted rather than sent as null when there is no gate, which is what // `events/spec.js` stores for the same reason: a spec full of // `"advance": null` makes the first phase to gain one look like an edit to // every phase in the version diff. ...(where ? { advance: where } : {}), steps: (phase.steps || []).map((step, si) => { const out = { actionId: step.actionId } if (step.label) out.label = step.label if (step.onFailure) out.onFailure = step.onFailure const parsed = parseParams(step.paramsText) if (parsed.error) { errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`) } else { out.params = parsed.params } return out }), } }) if (errors.length) return { ok: false, errors } return { ok: true, payload: { title: form.title, summary: form.summary || null, body: form.body || null, imageUrl: form.imageUrl || null, seriesId: form.seriesId ? Number(form.seriesId) : null, seriesOrder: Number(form.seriesOrder) || 0, concurrencyKey: form.concurrencyKey || null, graceSeconds: Number(form.graceSeconds), timezone: form.timezone, listed: Boolean(form.listed), listed: Boolean(form.listed), spec: { schedule: scheduleFromForm(form), phases }, }, } } // ── The schedule (Phase 4) ───────────────────────────────────────────────── // // The four closed shapes of §E, mirrored so the form can render one and the // preview can describe it. `events/spec.js` and `events/recurrence.js` remain // the deciders — this is what makes the form a form rather than a text box, and // it is the whole reason the schedule is not a cron string: a closed set has a // dropdown, and an operator can proofread a dropdown. export const WEEKDAYS = [ 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', ] export const SCHEDULE_KINDS = [ { value: 'manual', label: 'Started by hand' }, { value: 'once', label: 'Once, at a set time' }, { value: 'weekly', label: 'Weekly, on chosen days' }, { value: 'monthly', label: 'Monthly, on the nth weekday' }, ] // 1..4 and "last". There is no fifth: every month has a first through fourth of // every weekday, and "last" is what a month with five Fridays makes different // from "fourth" (org lead, 2026-09-02). export const MONTHLY_NTHS = [ { value: 1, label: 'First' }, { value: 2, label: 'Second' }, { value: 3, label: 'Third' }, { value: 4, label: 'Fourth' }, { value: -1, label: 'Last' }, ] const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1) /** * A schedule in words, in the event's own zone. * * The server says the same thing in `events/recurrence.js#describe`, and the two * are allowed to differ on wording but not on meaning — this one is what an * author reads while they are still typing, before anything has been saved. */ export function describeSchedule(schedule, timezone = 'UTC') { if (!schedule || typeof schedule !== 'object') return 'No schedule' const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth)) switch (schedule.kind) { case 'manual': return 'Started by hand — nothing happens until an admin presses Start' case 'once': { if (!schedule.at) return 'Once — no date chosen yet' return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})` } case 'weekly': { const days = (schedule.days || []).map(capitalise) if (!days.length || !schedule.time) return 'Weekly — choose days and a time' const list = days.length === 1 ? days[0] : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}` return `Every ${list} at ${schedule.time} (${timezone})` } case 'monthly': { if (!nth || !schedule.weekday || !schedule.time) { return 'Monthly — choose a week, a weekday and a time' } return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})` } default: return 'No schedule' } } /** * The schedule half of the editor's working state. * * Every shape's fields are kept side by side rather than cleared when the kind * changes, so an author who clicks Weekly, then Monthly, then back has not lost * the days they picked. `scheduleFromForm` reads only the fields the chosen kind * uses, which is what keeps the request body a clean single shape. */ export function scheduleFormFrom(schedule) { const s = schedule || {} return { scheduleKind: s.kind || 'manual', scheduleAt: s.kind === 'once' ? s.at || '' : '', scheduleDays: s.kind === 'weekly' ? s.days || [] : [], scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1', scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday', scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00', } } /** The schedule the form describes, as the spec object the server expects. */ export function scheduleFromForm(form) { switch (form.scheduleKind) { case 'once': return { kind: 'once', at: form.scheduleAt } case 'weekly': return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime } case 'monthly': return { kind: 'monthly', nth: Number(form.scheduleNth), weekday: form.scheduleWeekday, time: form.scheduleTime, } default: return { kind: 'manual' } } } /** * What a calendar entry is, and therefore what may be done with it. * * A `run` is a row: it has a console and somebody can cancel it. A `projected` * entry is arithmetic the runner has not reached yet — there is nothing to open * and nothing to stop, and an operator who treats one as a booking has been * misled by the UI rather than by the server. */ export const isProjected = (entry) => entry?.kind === 'projected' /** An empty box is `{}`, not a parse error — a step may legitimately take none. */ export function parseParams(text) { const raw = (text || '').trim() if (!raw) return { params: {} } let value try { value = JSON.parse(raw) } catch (err) { return { error: `the params are not valid JSON (${err.message})` } } if (value === null || typeof value !== 'object' || Array.isArray(value)) { return { error: 'the params must be a JSON object' } } return { params: value } } // ── Step params, as a form (Phase 13) ───────────────────────────── // // §I: the step editor is *"the condition builder, exactly — core serves a // catalog, the module declared the schema, core renders a form it does not // understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for // this, and everything the form needs was already in the catalog: a param's // name, type, whether it is required, its description, its example, and the // option source behind it. // // **The JSON stays as the storage and as the escape hatch, and both halves of // that matter.** As storage, because `payloadFromForm` already builds a request // out of it and a second representation would be two things to keep in step. As // an escape hatch, because a form can only render what the declaration // describes — and a step may legitimately hold something it does not. // // The rule for when the form gives way is the CONDITION BUILDER'S rule, which is // the reason this reads as a port of it rather than as a new idea: a value the // editor cannot round-trip is SHOWN rather than silently rewritten. Flattening // `A and (B or C)` there and dropping an undeclared param here are the same // mistake — a save that looks clean and means something else. /** The two ways a step's params are edited. */ export const PARAM_FORM = 'form' export const PARAM_JSON = 'json' /** * Can this step's params be rendered as a form without losing anything? * * `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold. * Three things make one, and none of them is an error — each is a step that has * to be edited as JSON: * * • **the action is dormant.** There is no declaration, so there are no fields. * A form here would render nothing and look like a step with no params. * • **a param the action does not declare.** The save refuses it by name, which * is what the author needs to see — and a form that dropped it would post a * step that saves cleanly having deleted something they typed. * • **a value no single control can hold** — an object or an array against a * scalar declaration. */ export function paramsRenderable(action, params) { if (!action) return { ok: false, reason: 'the module that registered this action is not installed' } const declared = new Map((action.params || []).map((p) => [p.name, p])) for (const [name, value] of Object.entries(params || {})) { if (!declared.has(name)) { return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` } } if (value !== null && typeof value === 'object') { return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` } } } return { ok: true } } /** * Which mode should this step open in? * * The author's own choice wins whenever the form COULD render the step — an * author who switched to JSON stays in JSON. What they cannot do is stay in a * form that would lose something, so an unrenderable step is forced to JSON * whatever the choice was, and the reason is returned so the screen can say it. */ export function paramsMode(step, action) { const parsed = parseParams(step?.paramsText) if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error } const renderable = paramsRenderable(action, parsed.params) if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason } return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null } } /** One declared param's current value, as the control holds it. */ export function paramValue(step, name) { const parsed = parseParams(step?.paramsText) if (parsed.error) return undefined return parsed.params[name] } /** * Write one param, and give back the whole box. * * **An empty field REMOVES the key rather than posting an empty string**, and * that is the server's own reading rather than a convenience: `checkParams` * treats `undefined`, `null` and `''` alike — absent — so a required param left * blank comes back as *"is required"*, which is the error the author needs, * instead of as a type complaint about `""`. * * **A value that does not parse is passed through as typed.** `coerceLiteral` is * the engagement builder's, unchanged, and its rule is the one that matters * here too: half of `-` is not a number, and turning it into `NaN` or `0` while * somebody is still typing would either post a value they never wrote or make * the field impossible to type a negative into. The server's type check then * names the param. * * Re-serialising the whole object rather than splicing text, for `pickParam`'s * reason: a string edit that produced valid-looking JSON with a duplicate key * would be a value the editor and the server read differently. */ export function setParam(step, name, raw, type) { const parsed = parseParams(step?.paramsText) if (parsed.error) return step?.paramsText || '{}' const next = { ...parsed.params } if (raw === '' || raw === undefined || raw === null) delete next[name] else next[name] = coerceLiteral(type, raw) return JSON.stringify(next, null, 2) } /** * A stored `datetime` as a `datetime-local` input wants it, and back. * * The server normalises a datetime param to an ISO string (`conditions.js` * `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The * slice is the whole conversion in one direction; in the other the input's own * text is a moment `new Date()` parses, so it is posted as typed and the server * does the normalising — one implementation of what a datetime is, and it is * not this one. */ export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '') /** * Everything the meter needs out of the form, and nothing else. * * The price route takes a spec, not a definition: no title, no schedule, no * series. Sending the whole payload would put a document in front of a route * that reads two fields of it — and would fail the moment the rest of the form * is mid-edit, which is exactly when the meter is being read. * * A step whose params do not parse is sent with none rather than dropped, so a * half-typed JSON box costs its own step's draw and not the phase's. */ export function priceBodyFrom(form) { return { phases: (form?.phases || []).map((phase) => ({ key: phase.key || null, steps: (phase.steps || []).map((step) => ({ actionId: step.actionId || '', params: parseParams(step.paramsText).params || {}, })), })), } } /** * Is this plan worth pricing at all? * * A meter that fires on an empty form asks the server what nothing costs, on * every keystroke of the title field. One step with an action chosen is the * threshold, because that is the first moment there is an answer. */ export const worthPricing = (form) => (form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId)) // ── Rendering what happened ──────────────────────────────────────────────── const STATUS_WORDS = { scheduled: 'Scheduled', starting: 'Starting', running: 'Running', paused: 'Paused', ending: 'Winding down', completed: 'Completed', cancelled: 'Cancelled', failed: 'Failed', missed: 'Missed', } export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown' const KIND_WORDS = { 'run.created': 'Occurrence created', 'run.status': 'Run status', 'run.health': 'Health', 'run.blocked': 'Held off', 'phase.entered': 'Phase entered', 'phase.completed': 'Phase completed', 'step.status': 'Step', 'step.retry': 'Step retried', 'step.parked': 'Waiting on a human', 'phase.gate': 'Advance condition set', 'condition.evaluated': 'Condition evaluated', 'phase.advanced': 'Phase advanced', // Phase 6. "Refused" reads differently from "Step" on purpose: an operator // scanning a stopped run needs to see that nothing is broken. 'step.refused': 'Refused', 'run.budget': 'Caps', 'version.verified': 'Dry run passed', note: 'Note', } export const logKindWord = (kind) => KIND_WORDS[kind] || kind /** * One log line as a sentence. * * The `detail` of a human control carries `control` and `by`, which is what * separates "the runner paused this because a world write failed" from "somebody * pressed pause" — the two are the same transition and the console has to be * able to tell them apart at a glance. */ export function describeLogLine(line) { const d = line?.detail || {} const by = d.by ? ' by staff' : '' switch (line?.kind) { case 'run.status': return d.control ? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}` : `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}` case 'run.health': return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}` case 'run.blocked': return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"` case 'phase.entered': return `Entered ${line.phase} (${d.steps ?? '?'} steps)` case 'phase.completed': return `${line.phase} finished` case 'step.parked': return `${d.action} is waiting on a human` case 'step.retry': return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}` case 'step.status': return d.control ? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}` : `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}` case 'run.created': return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}` case 'phase.gate': return d.kind === 'after' ? `${line.phase} advances ${d.after} after it started` : `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}` // Both outcomes are logged, and the near miss is the useful one: it is the // difference between "the boss did spawn, in the wrong region" and "no boss // has spawned", which look identical on every other line of this log. case 'condition.evaluated': return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${ d.satisfied ? ', condition met' : '' }` case 'phase.advanced': return d.because === 'forced' ? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}` : `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s` // Phase 6. `step.refused` is its own kind rather than a `step.status` for a // reason an operator feels at 2am: a refusal is not a failure, and the line // has to say which deployment rule stopped it -- the answer to "not enabled" // is a switch, and the answer to "over the cap" is a number. case 'step.refused': return `${d.action} refused: ${d.error}` case 'run.budget': return (d.dimensions || []) .map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`) .join(', ') || 'no caps apply to this run' case 'version.verified': return `Version ${d.version} passed its dry run — scheduled occurrences may start` default: return logKindWord(line?.kind) } }