// ── 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. // 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. */ export function runControlsFor(run) { if (!run) return { pause: false, resume: false, cancel: false } const terminal = isTerminalRun(run.status) return { pause: ['starting', 'running'].includes(run.status), resume: run.status === 'paused', cancel: !terminal, } } /** * 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 box 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 as a JSON object with the right * keys and plausible values rather than as an empty `{}` an author has to guess * the shape of. It is the nearest a raw JSON box gets to the schema-driven form * Phase 13 replaces it with, and it costs nothing the catalog was not already * serving. */ 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: [] } } /** 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', scheduleKind: spec.schedule?.kind || 'manual', phases: (spec.phases || []).map((p) => ({ key: p.key || '', label: p.label || '', 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), })), })), } } /** * 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) { const errors = [] const phases = (form.phases || []).map((phase, pi) => ({ key: phase.key, label: phase.label, 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, spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases }, }, } } /** 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 } } // ── 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', 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)' : ''}` default: return logKindWord(line?.kind) } }