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
311 lines
13 KiB
JavaScript
311 lines
13 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import {
|
||
runControlsFor,
|
||
stepControlsFor,
|
||
isParked,
|
||
lastStartedSeqOf,
|
||
formFromDefinition,
|
||
payloadFromForm,
|
||
parseParams,
|
||
blankStep,
|
||
blankPhase,
|
||
describeLogLine,
|
||
runStatusWord,
|
||
} from '../src/lib/eventAuthoring.js'
|
||
|
||
// lib/eventAuthoring.js — what the three Events screens say and what they let
|
||
// staff press (EVENTS.md §I, Phase 3).
|
||
//
|
||
// None of this is a boundary: `events/spec.js` decides what may be saved and the
|
||
// six control statements decide what may happen to a run, each of them a
|
||
// compare-and-set that re-checks the status this file only predicted.
|
||
//
|
||
// **The controls get most of the tests, and the reason is worth stating.** A
|
||
// button offered that the server refuses is not a wrong write — but it is the
|
||
// failure an operator meets at 2am, on the screen they opened because something
|
||
// is already going wrong, about the run they are trying to stop. So the guards
|
||
// are deliberately written twice and this is where the copy is checked against
|
||
// the original.
|
||
|
||
const run = (over = {}) => ({ id: 1, status: 'running', currentPhase: 'main', ...over })
|
||
const step = (over = {}) => ({
|
||
id: 10,
|
||
phase: 'main',
|
||
seq: 0,
|
||
status: 'pending',
|
||
parked: false,
|
||
...over,
|
||
})
|
||
|
||
// ── The run controls ───────────────────────────────────────────────────────
|
||
|
||
test('pause is offered only for a run in flight', () => {
|
||
assert.equal(runControlsFor(run({ status: 'running' })).pause, true)
|
||
assert.equal(runControlsFor(run({ status: 'starting' })).pause, true)
|
||
// A scheduled occurrence that should not happen is cancelled, not paused:
|
||
// resuming one after its grace window would produce a `missed` from a button
|
||
// labelled resume.
|
||
assert.equal(runControlsFor(run({ status: 'scheduled' })).pause, false)
|
||
assert.equal(runControlsFor(run({ status: 'paused' })).pause, false)
|
||
})
|
||
|
||
test('cancel is offered right up to the moment a run goes terminal, and never after', () => {
|
||
for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
|
||
assert.equal(runControlsFor(run({ status })).cancel, true, `${status} should be cancellable`)
|
||
}
|
||
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
|
||
assert.equal(runControlsFor(run({ status })).cancel, false, `${status} should not be`)
|
||
}
|
||
})
|
||
|
||
test('resume is offered for exactly one status', () => {
|
||
assert.equal(runControlsFor(run({ status: 'paused' })).resume, true)
|
||
assert.equal(runControlsFor(run({ status: 'running' })).resume, false)
|
||
})
|
||
|
||
// ── The step controls ──────────────────────────────────────────────────────
|
||
|
||
test('a parked step is running with nothing holding it, and only that', () => {
|
||
assert.equal(isParked(step({ status: 'running', parked: true })), true)
|
||
assert.equal(isParked(step({ status: 'running', parked: false })), false, 'a live lease is a dispatch')
|
||
assert.equal(isParked(step({ status: 'pending', parked: true })), false)
|
||
})
|
||
|
||
test('confirm is offered for a parked cue and for nothing else', () => {
|
||
const r = run()
|
||
const parked = step({ status: 'running', parked: true })
|
||
assert.equal(stepControlsFor(r, parked, [parked]).confirm, true)
|
||
|
||
const dispatching = step({ status: 'running', parked: false })
|
||
assert.equal(stepControlsFor(r, dispatching, [dispatching]).confirm, false)
|
||
|
||
const pending = step()
|
||
assert.equal(stepControlsFor(r, pending, [pending]).confirm, false)
|
||
})
|
||
|
||
test('skip is offered for a pending step and a parked cue', () => {
|
||
const r = run()
|
||
const pending = step()
|
||
const parked = step({ id: 11, seq: 1, status: 'running', parked: true })
|
||
const dispatching = step({ id: 12, seq: 2, status: 'running', parked: false })
|
||
const failed = step({ id: 13, seq: 3, status: 'failed' })
|
||
const steps = [pending, parked, dispatching, failed]
|
||
|
||
assert.equal(stepControlsFor(r, pending, steps).skip, true)
|
||
assert.equal(stepControlsFor(r, parked, steps).skip, true)
|
||
assert.equal(stepControlsFor(r, dispatching, steps).skip, false)
|
||
// A failed step does not need skipping: the runner already steps over it, so
|
||
// resuming the run carries the phase past it.
|
||
assert.equal(stepControlsFor(r, failed, steps).skip, false)
|
||
})
|
||
|
||
test('retry is offered for the failed step a paused run is stopped at', () => {
|
||
const r = run({ status: 'paused' })
|
||
const done = step({ id: 1, seq: 0, status: 'done' })
|
||
const failed = step({ id: 2, seq: 1, status: 'failed' })
|
||
const pending = step({ id: 3, seq: 2, status: 'pending' })
|
||
const steps = [done, failed, pending]
|
||
|
||
assert.equal(stepControlsFor(r, failed, steps).retry, true)
|
||
assert.equal(stepControlsFor(r, done, steps).retry, false)
|
||
assert.equal(stepControlsFor(r, pending, steps).retry, false)
|
||
})
|
||
|
||
test('retry is NOT offered for a failed step the run has moved past', () => {
|
||
// The case the server guard exists for, and the one this copy of it has to
|
||
// agree about: a phase that carried on past an `on_failure: skip` failure and
|
||
// then paused at a later step. Offering retry on the first would re-queue a row
|
||
// behind the runner's own cursor, where it sits pending for ever.
|
||
const r = run({ status: 'paused' })
|
||
const skippedOver = step({ id: 1, seq: 0, status: 'failed' })
|
||
const carriedOn = step({ id: 2, seq: 1, status: 'done' })
|
||
const stoppedAt = step({ id: 3, seq: 2, status: 'failed' })
|
||
const notYet = step({ id: 4, seq: 3, status: 'pending' })
|
||
const steps = [skippedOver, carriedOn, stoppedAt, notYet]
|
||
|
||
assert.equal(stepControlsFor(r, skippedOver, steps).retry, false)
|
||
assert.equal(stepControlsFor(r, stoppedAt, steps).retry, true)
|
||
})
|
||
|
||
test('retry is not offered while the run is still running, or in a phase it has left', () => {
|
||
const failed = step({ status: 'failed' })
|
||
assert.equal(stepControlsFor(run({ status: 'running' }), failed, [failed]).retry, false)
|
||
|
||
const old = step({ phase: 'one', status: 'failed' })
|
||
const r = run({ status: 'paused', currentPhase: 'two' })
|
||
assert.equal(stepControlsFor(r, old, [old]).retry, false)
|
||
})
|
||
|
||
test('no control is offered on a run that is over', () => {
|
||
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
|
||
const parked = step({ status: 'running', parked: true })
|
||
assert.deepEqual(stepControlsFor(run({ status }), parked, [parked]), {
|
||
confirm: false,
|
||
skip: false,
|
||
retry: false,
|
||
})
|
||
}
|
||
})
|
||
|
||
test('lastStartedSeqOf is the furthest step of the phase, and null when none has run', () => {
|
||
const steps = [
|
||
step({ id: 1, seq: 0, status: 'failed' }),
|
||
step({ id: 2, seq: 1, status: 'done' }),
|
||
step({ id: 3, seq: 2, status: 'pending' }),
|
||
step({ id: 4, seq: 0, phase: 'other', status: 'done' }),
|
||
]
|
||
assert.equal(lastStartedSeqOf(steps, 'main'), 1)
|
||
assert.equal(lastStartedSeqOf([step({ status: 'pending' })], 'main'), null)
|
||
assert.equal(lastStartedSeqOf(steps, 'nothing-here'), null)
|
||
})
|
||
|
||
// ── The definition form ────────────────────────────────────────────────────
|
||
|
||
const ANNOUNCE = {
|
||
id: 'core.announce',
|
||
label: 'Announce',
|
||
risk: 'notify',
|
||
params: [
|
||
{ name: 'leg', type: 'string', required: true, example: 'discord' },
|
||
{ name: 'title', type: 'string', required: false, example: 'The gates open' },
|
||
{ name: 'body', type: 'string', required: true, example: 'A caravan was sighted.' },
|
||
],
|
||
}
|
||
|
||
test('a new step arrives prefilled from the action’s declared examples', () => {
|
||
const fresh = blankStep(ANNOUNCE)
|
||
assert.equal(fresh.actionId, 'core.announce')
|
||
assert.deepEqual(JSON.parse(fresh.paramsText), {
|
||
leg: 'discord',
|
||
title: 'The gates open',
|
||
body: 'A caravan was sighted.',
|
||
})
|
||
})
|
||
|
||
test('a new phase never collides with an existing key', () => {
|
||
// Two phases sharing a key would silently collapse at materialisation —
|
||
// `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored
|
||
// steps would never exist. The server refuses it; the form must not propose it.
|
||
const first = blankPhase([])
|
||
const second = blankPhase([first])
|
||
const third = blankPhase([first, second])
|
||
assert.equal(new Set([first.key, second.key, third.key]).size, 3)
|
||
})
|
||
|
||
test('the form round-trips a definition without losing a step', () => {
|
||
const event = {
|
||
title: 'Invasion',
|
||
graceSeconds: 600,
|
||
timezone: 'Europe/Berlin',
|
||
concurrencyKey: 'invasion:{region}',
|
||
spec: {
|
||
schedule: { kind: 'manual' },
|
||
phases: [
|
||
{
|
||
key: 'warn',
|
||
label: 'Warning',
|
||
steps: [
|
||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
}
|
||
|
||
const built = payloadFromForm(formFromDefinition(event))
|
||
assert.equal(built.ok, true)
|
||
assert.deepEqual(built.payload.spec.phases, [
|
||
{
|
||
key: 'warn',
|
||
label: 'Warning',
|
||
steps: [
|
||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||
],
|
||
},
|
||
])
|
||
assert.equal(built.payload.graceSeconds, 600)
|
||
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
|
||
})
|
||
|
||
test('an unchosen onFailure is omitted rather than invented', () => {
|
||
// The server defaults it from the action's risk class, which is the whole
|
||
// reason `risk` is required at registration. A form that posted a value would
|
||
// silently override that — turning a `change` action's `pause` into a `skip`
|
||
// and advancing a run over a half-changed world.
|
||
const form = formFromDefinition({
|
||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||
})
|
||
const built = payloadFromForm(form)
|
||
assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false)
|
||
})
|
||
|
||
test('a params box that is not JSON is refused with the step named', () => {
|
||
const form = formFromDefinition({
|
||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||
})
|
||
form.phases[0].steps[0].paramsText = '{ leg: discord }'
|
||
|
||
const built = payloadFromForm(form)
|
||
assert.equal(built.ok, false)
|
||
assert.match(built.errors[0], /Phase 1 "Main", step 1/)
|
||
})
|
||
|
||
test('an empty params box is an empty object, not an error', () => {
|
||
assert.deepEqual(parseParams('').params, {})
|
||
assert.deepEqual(parseParams(' ').params, {})
|
||
assert.ok(parseParams('[1,2]').error, 'an array is not a params object')
|
||
assert.ok(parseParams('"leg"').error)
|
||
})
|
||
|
||
// ── Rendering what happened ────────────────────────────────────────────────
|
||
|
||
test('a human transition reads differently from the runner’s own', () => {
|
||
// Both are `run.status` rows. `detail.control` is the only thing that separates
|
||
// "the runner paused this because a world write failed" from "somebody pressed
|
||
// pause", and the console has to tell them apart at a glance.
|
||
const byRunner = describeLogLine({
|
||
kind: 'run.status',
|
||
detail: { from: 'running', to: 'paused', because: 'core.spawn' },
|
||
})
|
||
const byPerson = describeLogLine({
|
||
kind: 'run.status',
|
||
detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' },
|
||
})
|
||
|
||
assert.match(byRunner, /Running → Paused/)
|
||
assert.match(byRunner, /core\.spawn/)
|
||
assert.match(byPerson, /pause/)
|
||
assert.match(byPerson, /by staff/)
|
||
assert.match(byPerson, /shard is lagging/)
|
||
})
|
||
|
||
test('the log lines a run produces all render as something', () => {
|
||
const lines = [
|
||
{ kind: 'run.created', detail: { version: 3, rehearsal: true } },
|
||
{ kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } },
|
||
{ kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } },
|
||
{ kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } },
|
||
{ kind: 'phase.completed', phase: 'warn', detail: {} },
|
||
{ kind: 'step.parked', detail: { action: 'core.cue' } },
|
||
{ kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } },
|
||
{ kind: 'step.status', detail: { action: 'core.wait', to: 'done' } },
|
||
{ kind: 'note', detail: {} },
|
||
]
|
||
for (const line of lines) {
|
||
const text = describeLogLine(line)
|
||
assert.equal(typeof text, 'string')
|
||
assert.ok(text.length > 0, `${line.kind} rendered as nothing`)
|
||
assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`)
|
||
}
|
||
})
|
||
|
||
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
|
||
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||
assert.ok(runStatusWord(s).length > 0)
|
||
}
|
||
assert.equal(runStatusWord('something-new'), 'something-new')
|
||
})
|