Three screens, an Events nav group and the six live run controls Phase 1 left
absent on purpose because nothing was in flight. An admin can now author,
publish, start and watch an event that announces things and cues a human; a
moderator can stop one that is going wrong.
Six controls, not eight. `advance` is absent because a phase today advances when
its steps go terminal — the per-step skip already does that — and Phase 5 is what
gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`,
until Phase 8's ledger exists. Every control is a compare-and-set on the status it
may act from, so a console rendered thirty seconds ago cannot act on a run that
has moved.
Fixes a defect in the Phase 2 runner: `advanceRun` drained up to
EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the
tick, so a pause pressed mid-batch did nothing for up to 24 more steps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
// ── 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)
|
|
}
|
|
}
|