feat(events): the minimal admin surface (Phase 3)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 5m26s
PR Checks / client-build (pull_request) Successful in 8m30s

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
This commit is contained in:
2026-09-02 08:39:35 -05:00
parent 2ba397eff7
commit 7b570c8ea1
20 changed files with 3775 additions and 8 deletions

View File

@@ -49,6 +49,9 @@ import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
import EventEditor from './routes/admin/views/EventEditor.jsx'
import EventRun from './routes/admin/views/EventRun.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -192,6 +195,18 @@ export default function App() {
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
<Route path="teams" element={<TeamsAdmin />} />
{/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement:
§K makes every read here `staff`, and the moderator's whole
power over this feature is the run console — cancelling a run
that is doing something wrong at 2am. The narrower gates are
applied per action instead: authoring is admin+editor, publish
and start are admin only (§N2), and each button follows the
route it calls. `runs/:runId` is declared before `:id` so the
literal segment is never read as a definition id. */}
<Route path="events" element={<EventsAdmin />} />
<Route path="events/runs/:runId" element={<EventRun />} />
<Route path="events/new" element={<EventEditor />} />
<Route path="events/:id" element={<EventEditor />} />
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
on top of the group's staff gate, because this is the group that

View File

@@ -472,6 +472,45 @@ export const api = {
setEngagementRetention: (body) =>
req('/admin/engagement/retention', { method: 'PUT', body }),
// Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring
// is admin+editor, publish and start are admin ONLY, and the six live
// controls are admin+moderator — the one gate in this feature wider than
// admin, because stopping a run at 2am is incident response and starting
// one is not (§N2). The buttons follow the same split, and the server
// re-checks every one of them.
listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`),
getEvent: (id) => req(`/admin/events/${id}`),
createEvent: (body) => req('/admin/events', { method: 'POST', body }),
updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }),
publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }),
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
eventCatalog: () => req('/admin/events/catalog'),
eventSeries: () => req('/admin/events/series'),
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
listEventRuns: ({ definitionId, status, limit } = {}) => {
const qs = new URLSearchParams()
if (definitionId) qs.set('definitionId', String(definitionId))
if (status) qs.set('status', status)
if (limit) qs.set('limit', String(limit))
const suffix = qs.toString()
return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`)
},
getEventRun: (runId) => req(`/admin/events/runs/${runId}`),
getEventRunLog: (runId, limit) =>
req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`),
pauseEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }),
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
cancelEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }),
confirmEventStep: (runId, stepId, note) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
skipEventStep: (runId, stepId, reason) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }),
retryEventStep: (runId, stepId) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }),
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.

View File

@@ -0,0 +1,296 @@
// ── 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)
}
}

View File

@@ -53,6 +53,7 @@ const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4"
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /><circle cx="12" cy="15" r="1.4" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -120,6 +121,20 @@ export const NAV = [
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
],
},
{
// Its own top-level group rather than a row under Content, and staff-wide
// rather than admin-only. Both follow EVENTS.md §K: every read here is
// `staff`, and the moderator's entire power over this feature is the run
// console — the thing they open when an event is doing something wrong at
// 2am. Hiding it from them would leave the one role that exists for incident
// response unable to see the incident. The narrower gates live on the
// actions: authoring is admin+editor and publish/start are admin only, both
// enforced server-side and mirrored on the buttons.
title: 'Events',
items: [
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
],
},
{
title: 'System',
items: [
@@ -202,6 +217,8 @@ const TITLES = {
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
'/admin/events': 'Events',
'/admin/events/new': 'New event',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
@@ -222,6 +239,10 @@ function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/users/')) return 'User'
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
// /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
// belong to the same section as far as the page title is concerned.
if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
if (pathname.startsWith('/admin/events/')) return 'Event'
return 'Admin'
}

View File

@@ -0,0 +1,505 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import {
formFromDefinition,
payloadFromForm,
blankPhase,
blankStep,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
//
// **A vertical timeline, not a node graph**, and that is a decision about what
// the engine can actually do rather than a matter of taste. The condition
// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at
// depth five — so a canvas would promise power this project has never handed an
// operator. Phases in order, each with its steps in order, says exactly what the
// runner does with them.
//
// **Core renders no game word here.** Every label on a step comes from the
// action's own registration — its `label`, its params' names, their descriptions
// and their examples — so an installed module's vocabulary appears without core
// knowing any of it, and `check:modules` already fails core's build on a UO
// identifier.
//
// **The params box is a raw JSON field and it is captioned as a placeholder**,
// because that is what it is: Phase 13 replaces it with the schema-driven form
// the condition builder already models. What makes it usable in the meantime is
// that a new step arrives PREFILLED from the action's declared examples, and the
// declaration is rendered beside the box — every param's name, type, whether it
// is required, and what a value looks like. All of that was already in the
// catalog; none of it is a second copy of anything.
const DORMANT_NOTE =
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
export default function EventEditor() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const isNew = id === 'new'
const [form, setForm] = useState(null)
const [event, setEvent] = useState(null)
const [catalog, setCatalog] = useState(null)
const [series, setSeries] = useState([])
const [versions, setVersions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [problems, setProblems] = useState([])
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const isAdmin = user?.role === 'admin'
// Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
// — the run console is their whole job and a definition is what a run is OF. But
// authoring is `admin` + `editor`, so Save has to follow the route it calls.
// Offering it and letting the server answer 403 is the shape §K calls "a gate
// nobody notices was missing", only inverted: a button that does nothing.
const mayAuthor = isAdmin || user?.role === 'editor'
const load = useCallback(async () => {
const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()])
setCatalog(cat)
setSeries(ser.series || [])
if (isNew) {
setEvent(null)
setVersions([])
setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
return
}
const [{ event: loaded }, { versions: history }] = await Promise.all([
api.admin.getEvent(id),
api.admin.listEventVersions(id),
])
setEvent(loaded)
setVersions(history || [])
setForm(formFromDefinition(loaded))
}, [id, isNew])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load()
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load])
const actions = useMemo(() => catalog?.actions || [], [catalog])
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
const setPhase = (pi, patch) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)),
}))
const setStep = (pi, si, patch) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) =>
i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
),
}))
const movePhase = (pi, delta) =>
setForm((f) => {
const next = [...f.phases]
const to = pi + delta
if (to < 0 || to >= next.length) return f
;[next[pi], next[to]] = [next[to], next[pi]]
return { ...f, phases: next }
})
const moveStep = (pi, si, delta) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) => {
if (i !== pi) return p
const steps = [...p.steps]
const to = si + delta
if (to < 0 || to >= steps.length) return p
;[steps[si], steps[to]] = [steps[to], steps[si]]
return { ...p, steps }
}),
}))
/**
* Changing a step's action REPLACES its params with the new action's examples.
*
* The alternative — keeping what was typed — leaves an object whose keys belong
* to a different action, and the save refuses it with "x is not a param of y"
* for every one of them. Replacing is the honest move and it is not
* destructive in any way an author minds: they have just said this step does
* something else.
*/
const changeAction = (pi, si, actionId) => {
const action = actionById.get(actionId)
const fresh = blankStep(action)
setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' })
}
const save = async () => {
setBusy(true)
setProblems([])
setNotice(null)
const built = payloadFromForm(form)
if (!built.ok) {
setProblems(built.errors)
setBusy(false)
return
}
try {
if (isNew) {
const result = await api.admin.createEvent(built.payload)
navigate(`/admin/events/${result.event.id}`, { replace: true })
} else {
const result = await api.admin.updateEvent(id, built.payload)
setEvent(result.event)
setForm(formFromDefinition(result.event))
setNotice('Saved.')
}
} catch (err) {
// The server answers with every problem rather than the first, so an author
// fixing a spec does it in one pass rather than six round trips.
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
const publish = async () => {
setBusy(true)
setProblems([])
setNotice(null)
try {
const result = await api.admin.publishEvent(id)
setEvent(result.event)
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
setNotice(`Published as v${result.version}.`)
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
const start = async () => {
setBusy(true)
setProblems([])
try {
const result = await api.admin.startEventRun(id, {})
navigate(`/admin/events/runs/${result.run.id}`)
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
if (loading || !form) return <Loading />
if (error) return <ErrorState message={error} />
const archived = event?.state === 'archived'
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
<div>
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
{isNew ? 'New event' : event?.title}
</h2>
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
{isNew ? (
'The slug is derived from the title once and frozen afterwards — the public event page lives at it.'
) : (
<>
{event?.slug} · {event?.state}
{event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'}
</>
)}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{mayAuthor && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={save}>
{isNew ? 'Create draft' : 'Save'}
</button>
)}
{/* Publish and start are admin ONLY (§N2) and not the same gate as the
live controls: publishing commits a definition a schedule will later
start unattended. */}
{!isNew && isAdmin && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={publish}>
Publish
</button>
)}
{!isNew && isAdmin && event?.state === 'ready' && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={start}>
Start now
</button>
)}
</div>
</div>
{!mayAuthor && (
<p className="sans dim" style={{ fontSize: '0.82rem' }}>
You can read this definition and watch its runs. Editing and publishing an event are an
admin or editor's, and starting one is an admin's alone live control of a run already in
flight is yours.
</p>
)}
{archived && (
<p className="sans" style={{ fontSize: '0.84rem', color: '#d9c184' }}>
This definition is archived. It is kept so its past runs can still be explained, and it
cannot be edited or run again.
</p>
)}
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
{problems.length > 0 && (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d98b84' }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>That did not save:</p>
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.82rem' }}>
{problems.map((p) => <li key={p}>{p}</li>)}
</ul>
</div>
)}
{/* ── Basics ── */}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Title</span>
<input className="input" value={form.title} onChange={(e) => set({ title: e.target.value })} />
</label>
<label>
<span className="field-label">Series</span>
<select className="select" value={form.seriesId} onChange={(e) => set({ seriesId: e.target.value })}>
<option value="">Not part of a series</option>
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label>
<span className="field-label">Timezone</span>
<input className="input" value={form.timezone} onChange={(e) => set({ timezone: e.target.value })} />
</label>
<label>
<span className="field-label">Grace window (seconds)</span>
<input className="input" type="number" min="0" value={form.graceSeconds}
onChange={(e) => set({ graceSeconds: e.target.value })} />
</label>
<label>
<span className="field-label">Concurrency key</span>
<input className="input" value={form.concurrencyKey} placeholder="invasion:{region}"
onChange={(e) => set({ concurrencyKey: e.target.value })} />
</label>
</div>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="field-label">Summary</span>
<input className="input" value={form.summary} onChange={(e) => set({ summary: e.target.value })} />
</label>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="field-label">Storyline</span>
<textarea className="input" rows={4} value={form.body} onChange={(e) => set({ body: e.target.value })} />
</label>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
The grace window is how late this event may still start: past it an occurrence becomes
<em> missed</em> rather than beginning hours after it was announced. Two runs sharing a
concurrency key never overlap <code>{'{placeholders}'}</code> are filled from the run&rsquo;s own
params.
</p>
</div>
{/* ── Schedule ── */}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Schedule</h3>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
<strong>Started by hand.</strong> Recurrence once, weekly, monthly on the nth weekday
is computed in the event&rsquo;s own timezone and arrives in the next phase, with the calendar.
Until then an occurrence exists because somebody pressed <em>Start now</em>, and the
schedule shape a definition may carry is deliberately the single one the runner honours.
</p>
</div>
{/* ── The phase timeline ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Phases</h3>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={archived}
onClick={() => set({ phases: [...form.phases, blankPhase(form.phases)] })}>
Add phase
</button>
</div>
{form.phases.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
No phases yet. An event needs at least one phase with at least one step before it can be
published.
</p>
)}
{form.phases.map((phase, pi) => (
<div key={pi} className="panel-flat" style={{ padding: 14, marginBottom: 12, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 200px' }}>
<span className="field-label">Phase {pi + 1} label</span>
<input className="input" value={phase.label} onChange={(e) => setPhase(pi, { label: e.target.value })} />
</label>
<label style={{ flex: '0 1 180px' }}>
<span className="field-label">Key</span>
<input className="input" value={phase.key} onChange={(e) => setPhase(pi, { key: e.target.value })} />
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === 0} onClick={() => movePhase(pi, -1)}></button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === form.phases.length - 1} onClick={() => movePhase(pi, 1)}></button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
onClick={() => set({ phases: form.phases.filter((_, i) => i !== pi) })}>Remove</button>
</div>
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
The key is what the run console groups by and what &ldquo;phase 3 has not started&rdquo; names, so it
cannot change once runs exist. A phase advances when every one of its steps is finished;
advancing on a condition instead is a later phase.
</p>
<div style={{ marginTop: 12 }}>
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)
return (
<div key={si} style={{ borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Step {si + 1} action</span>
<select className="select" value={step.actionId} onChange={(e) => changeAction(pi, si, e.target.value)}>
<option value="">Pick an action</option>
{actions.map((a) => <option key={a.id} value={a.id}>{a.label} {a.id}</option>)}
{/* A step whose module has been uninstalled keeps its
action id, so the select must be able to show a value
that is not in the catalog rather than silently
resetting the step to nothing. */}
{step.actionId && !action && <option value={step.actionId}>{step.actionId} (not installed)</option>}
</select>
</label>
<label style={{ flex: '0 1 200px' }}>
<span className="field-label">If it fails</span>
<select className="select" value={step.onFailure} onChange={(e) => setStep(pi, si, { onFailure: e.target.value })}>
<option value="">
Default{action ? `${catalog?.onFailureByRisk?.[action.risk] || 'pause'}` : ''}
</option>
{(catalog?.onFailure || []).map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === 0} onClick={() => moveStep(pi, si, -1)}></button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === phase.steps.length - 1} onClick={() => moveStep(pi, si, 1)}></button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
onClick={() => setPhase(pi, { steps: phase.steps.filter((_, j) => j !== si) })}>Remove</button>
</div>
</div>
{step.dormant && (
<p className="sans" style={{ fontSize: '0.78rem', color: '#d9c184', margin: '8px 0 0' }}>{DORMANT_NOTE}</p>
)}
{action && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
{action.description} <span style={{ opacity: 0.75 }}>· risk: {action.risk} · reversible: {action.reversible}</span>
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: 12, marginTop: 10 }}>
<label>
<span className="field-label">Params (JSON)</span>
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem' }}
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
A raw JSON field, and a placeholder: the form that renders each param from its
declared type arrives with the authoring pass. What is saved is checked
against the declaration either way.
</span>
</label>
<div>
<span className="field-label">What this action takes</span>
{action ? (
<table className="adm-table" style={{ fontSize: '0.78rem' }}>
<tbody>
{(action.params || []).map((p) => (
<tr key={p.name}>
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
<code>{p.name}</code>
{p.required && <span style={{ color: '#d98b84' }}> *</span>}
</td>
<td className="adm-td dim">{p.type}</td>
<td className="adm-td">
{p.description}
<div className="dim">e.g. <code>{JSON.stringify(p.example)}</code></div>
</td>
</tr>
))}
{(action.params || []).length === 0 && (
<tr><td className="adm-td dim">This action takes no params.</td></tr>
)}
</tbody>
</table>
) : (
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
Pick an action and its parameters are listed here, straight from what the
module declared.
</p>
)}
</div>
</div>
</div>
)
})}
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 12 }} disabled={archived}
onClick={() => setPhase(pi, { steps: [...phase.steps, blankStep(actions[0])] })}>
Add step
</button>
</div>
</div>
))}
{!isNew && versions.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginTop: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Versions</h3>
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>
Publishing snapshots the whole spec into a version nothing ever edits. Editing this
definition while a run is live is free <strong>the live run keeps the version it
pinned</strong> and is unaffected by anything on this screen.
</p>
<table className="adm-table">
<tbody>
{versions.map((v) => (
<tr key={v.id}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>v{v.version}{v.current && <span className="dim"> · current</span>}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{new Date(v.publishedAt).toLocaleString()}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{v.publishedByUsername || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,352 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import {
runStatusWord,
isTerminalRun,
isParked,
runControlsFor,
stepControlsFor,
describeLogLine,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
//
// One run: where it is, what each of its steps did, what a human can still do
// about it, and the diagnostic log underneath. Staff-wide to read; the six
// controls are `admin` + `moderator`, and the server re-checks every one of them
// against the run's live status — this screen predicts, it does not decide.
//
// **It polls rather than streaming.** A run changes on the runner's tick, which
// is a fifteen-second clock, and a console watched for the length of an event is
// a tab left open for two hours: an SSE channel for that is a connection held
// per staff member for a screen that could not use the latency. The poll stops
// the moment the run reaches a terminal status, because a completed run has
// nothing further to say.
//
// **The parked step is the thing this screen exists to make impossible to
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
// stay that way for ever unless somebody presses confirm. It is called out above
// the step list rather than being one row in it.
const POLL_MS = 5000
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
completed: '#8fc79a',
}
const STEP_COLOR = {
done: '#8fc79a',
failed: '#d98b84',
refused: '#d9c184',
skipped: 'var(--muted)',
cancelled: 'var(--muted)',
}
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
export default function EventRun() {
const { runId } = useParams()
const [run, setRun] = useState(null)
const [steps, setSteps] = useState([])
const [counts, setCounts] = useState({})
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState(null)
const [notes, setNotes] = useState({})
const [reason, setReason] = useState('')
const alive = useRef(true)
const load = useCallback(async () => {
const [detail, log] = await Promise.all([
api.admin.getEventRun(runId),
api.admin.getEventRunLog(runId, 200),
])
if (!alive.current) return
setRun(detail.run)
setSteps(detail.steps || [])
setCounts(detail.counts || {})
setLines(log.log || [])
}, [runId])
useEffect(() => {
alive.current = true
;(async () => {
setLoading(true)
try {
await load()
setError(null)
} catch (err) {
if (alive.current) setError(err.message)
} finally {
if (alive.current) setLoading(false)
}
})()
return () => {
alive.current = false
}
}, [load])
// The poll, and its own off switch. A terminal run is not re-read: it cannot
// change, and a console left open on last night's completed event should not
// be a request every five seconds until the tab is closed.
useEffect(() => {
if (!run || isTerminalRun(run.status)) return undefined
const timer = setInterval(() => {
load().catch(() => {})
}, POLL_MS)
return () => clearInterval(timer)
}, [run, load])
/** Every control goes through here: press, reload, and surface a refusal. */
const act = async (fn) => {
setBusy(true)
setProblem(null)
try {
await fn()
await load()
} catch (err) {
// A 409 is the ordinary answer to a button pressed against a run that has
// moved on since the screen was drawn, so it is shown as a sentence rather
// than as an error state — and the reload above has already re-drawn the
// controls as they now stand.
setProblem(err.body?.errors?.[0] || err.message)
await load().catch(() => {})
} finally {
setBusy(false)
}
}
if (loading && !run) return <Loading />
if (error) return <ErrorState message={error} />
if (!run) return <ErrorState message="No such run." />
const controls = runControlsFor(run)
const parked = steps.filter(isParked)
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
<div>
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
<Link to={`/admin/events/${run.definitionId}`}>{run.definitionTitle}</Link>{' '}
<span className="dim" style={{ fontWeight: 400 }}>v{run.version}</span>
</h2>
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
Occurrence {when(run.scheduledFor)}
{run.scope ? ` · scope ${run.scope}` : ''}
{run.rehearsal ? ' · rehearsal' : ''}
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
{runStatusWord(run.status)}
{run.currentPhase && <span className="dim" style={{ fontSize: '0.82rem' }}> · {run.currentPhase}</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem' }}>
{run.health !== 'ok' && <span style={{ color: '#d9c184' }}>{run.health} · </span>}
{summary || 'no steps'}
{!isTerminalRun(run.status) && <span> · refreshing</span>}
</div>
</div>
</div>
{/* Health is not status, which is the whole reason the two are separate
columns — but the sentence has to agree with the status it sits beside.
A degraded RUNNING run is the interesting case: still going, already in
trouble. A degraded PAUSED run is not "still running", and saying so on
the one screen an operator opens to find out what stopped it would be
the console contradicting itself. Found in the browser walk. */}
{run.health === 'degraded' && !isTerminalRun(run.status) && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d9c184', marginTop: 10 }}>
{run.status === 'paused' ? (
<>
Something in this run failed, and it is waiting for a person. Resuming carries the phase
past the failed step; <em>Retry &amp; resume</em> puts that step back in the queue first.
</>
) : (
<>
Something in this run has already had to be retried. It is still running this is
what &ldquo;degraded&rdquo; means, and the log below says what happened.
</>
)}
</p>
)}
{run.lastError && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{run.lastError}</p>
)}
{problem && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{problem}</p>
)}
{/* ── The run controls ── */}
<div className="panel-flat" style={{ padding: '12px 14px', margin: '14px 0', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Reason (recorded with your name)</span>
<input className="input" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="optional" />
</label>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.pause}
onClick={() => act(() => api.admin.pauseEventRun(run.id, reason))}>
Pause
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.resume}
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
Resume
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
Cancel run
</button>
</div>
{isTerminalRun(run.status) && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>
This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
a run pins the version it started from so that it can still be explained later.
</p>
)}
{/* ── Waiting on a person ── */}
{parked.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Waiting on a person</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
Nothing else in this phase runs until each of these is confirmed. There is no timeout
a cue posted on Friday is still waiting on Monday.
</p>
{parked.map((step) => (
<div key={step.id} style={{ marginBottom: 10 }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.86rem' }}>
{step.params?.instruction || step.actionId}
{step.params?.assignee && <span className="dim"> for {step.params.assignee}</span>}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">What you did (optional)</span>
<input className="input" value={notes[step.id] || ''}
onChange={(e) => setNotes((n) => ({ ...n, [step.id]: e.target.value }))} />
</label>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.confirmEventStep(run.id, step.id, notes[step.id]))}>
Confirm done
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, notes[step.id]))}>
Skip it
</button>
</div>
</div>
))}
</div>
)}
{/* ── The steps ── */}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Steps</h3>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Phase</th>
<th className="adm-th">#</th>
<th className="adm-th">Action</th>
<th className="adm-th">Status</th>
<th className="adm-th">Attempts</th>
<th className="adm-th">Detail</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{steps.map((step) => {
const c = stepControlsFor(run, step, steps)
return (
<tr key={step.id}>
<td className="adm-td" style={{ fontSize: '0.8rem' }}>
{step.phase}
{step.phase === run.currentPhase && <span className="dim"> ·now</span>}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{step.seq + 1}</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{JSON.stringify(step.params)}
</div>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
{isParked(step) ? <span style={{ color: '#d9c184' }}>waiting</span> : step.status}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{step.attempts}
{step.dueAt && new Date(step.dueAt) > new Date() && (
<div style={{ fontSize: '0.74rem' }}>due {clock(step.dueAt)}</div>
)}
</td>
<td className="adm-td" style={{ fontSize: '0.78rem', maxWidth: 280, overflowWrap: 'anywhere' }}>
{step.lastError || ''}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{c.retry && (
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
onClick={() => act(() => api.admin.retryEventStep(run.id, step.id))}>
Retry &amp; resume
</button>
)}
{c.skip && !isParked(step) && (
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, reason))}>
Skip
</button>
)}
</td>
</tr>
)
})}
{steps.length === 0 && (
<tr><td className="adm-td dim" colSpan={7}>No steps have been materialised yet.</td></tr>
)}
</tbody>
</table>
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
Steps run strictly in order within a phase, and the phase ends when every one of them has
finished. A failed step is not retried by the runner past its attempt limit resuming a
paused run carries the phase past it, and <em>Retry &amp; resume</em> puts the step the run is
stopped at back in the queue.
</p>
{/* ── The log ── */}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>Log</h3>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
The run&rsquo;s own diagnostic record, newest first this is what answers &ldquo;why didn&rsquo;t phase 3
start?&rdquo; without reading server logs. Who published or started what is recorded separately, in
the activity log.
</p>
<div className="panel-flat">
<table className="adm-table">
<tbody>
{lines.map((line) => (
<tr key={line.id}>
<td className="adm-td dim" style={{ fontSize: '0.76rem', whiteSpace: 'nowrap' }}>{clock(line.at)}</td>
<td className="adm-td dim" style={{ fontSize: '0.76rem' }}>{line.phase || ''}</td>
<td className="adm-td" style={{ fontSize: '0.8rem' }}>{describeLogLine(line)}</td>
</tr>
))}
{lines.length === 0 && <tr><td className="adm-td dim">Nothing logged yet.</td></tr>}
</tbody>
</table>
</div>
</section>
)
}

View File

@@ -0,0 +1,260 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
// Admin → Events (EVENTS.md §I, Phase 3).
//
// Two tables on one screen: the definitions an operator authors, and the runs
// those definitions have produced. They are together rather than on two nav rows
// because the question this screen exists to answer is one question — "what is
// scheduled, and what is happening right now" — and the second half of it is the
// one somebody opens at 8pm on a Friday.
//
// **The waiting badge is the whole reason the run table is here rather than
// buried a click away.** A run parked on a GM cue looks perfectly healthy: it is
// `running`, nothing has failed, and it will stay that way for ever because it
// is waiting for a person who does not know they are being waited for. The count
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
// it before it can say so.
//
// What is NOT here: a calendar. Recurrence and the month view are Phase 4, and a
// definition today can only carry `schedule: { kind: 'manual' }` — so the honest
// list is a list, and the screen says as much rather than showing an empty grid.
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
}
const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' }
const when = (value) => (value ? new Date(value).toLocaleString() : '—')
export default function EventsAdmin() {
const { user } = useAuth()
const navigate = useNavigate()
const [events, setEvents] = useState([])
const [runs, setRuns] = useState([])
const [state, setState] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const isAdmin = user?.role === 'admin'
const mayAuthor = isAdmin || user?.role === 'editor'
const load = useCallback(async (nextState) => {
const [defs, runList] = await Promise.all([
api.admin.listEvents(nextState || undefined),
api.admin.listEventRuns({ limit: 50 }),
])
setEvents(defs.events || [])
setRuns(runList.runs || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load(state)
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load, state])
// "Start now" is an occurrence whose instant is the present, not a separate
// concept — the same route a scheduled occurrence will use in Phase 4. Admin
// only, deliberately (§N2): starting commits the deployment to everything the
// definition contains, unattended.
const startNow = async (event) => {
setBusy(true)
setNotice(null)
try {
const result = await api.admin.startEventRun(event.id, {})
navigate(`/admin/events/runs/${result.run.id}`)
} catch (err) {
setNotice(err.message)
} finally {
setBusy(false)
}
}
if (loading && !events.length && !runs.length) return <Loading />
if (error) return <ErrorState message={error} />
const live = runs.filter((r) => !isTerminalRun(r.status))
const waiting = live.filter((r) => r.waitingSteps > 0)
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
published as an immutable version, and every occurrence of it runs against the version it
pinned. Recurrence and the calendar arrive with the next phase for now an occurrence is
started by hand.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<label>
<span className="field-label">Show</span>
<select className="select" value={state} onChange={(e) => setState(e.target.value)}>
<option value="">All definitions</option>
<option value="draft">Drafts</option>
<option value="ready">Ready</option>
<option value="archived">Archived</option>
</select>
</label>
{mayAuthor && (
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/new">
New event
</Link>
)}
</div>
</div>
{notice && (
<p className="sans" style={{ fontSize: '0.84rem', color: '#d98b84', marginTop: 0 }}>{notice}</p>
)}
{waiting.length > 0 && (
<div className="panel-flat" style={{ padding: '12px 14px', marginBottom: 16, borderLeft: '3px solid #d9c184' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem' }}>
<strong>{waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a
person.</strong>{' '}
<span className="dim">
A cue holds its phase until somebody confirms it was done in-client nothing else will
move it.
</span>
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
{waiting.map((r) => (
<Link key={r.id} className="pill" style={{ fontSize: '0.74rem' }} to={`/admin/events/runs/${r.id}`}>
{r.definitionTitle} · {r.waitingSteps} waiting
</Link>
))}
</div>
</div>
)}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Definitions</h3>
{events.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{state ? 'Nothing matches that filter.' : 'No events have been authored yet.'}
</p>
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Event</th>
<th className="adm-th">State</th>
<th className="adm-th">Version</th>
<th className="adm-th">Series</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td className="adm-td" style={{ fontSize: '0.85rem' }}>
<Link to={`/admin/events/${e.id}`}>{e.title}</Link>
<div className="dim" style={{ fontSize: '0.76rem' }}>{e.slug}</div>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{STATE_WORD[e.state] || e.state}</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{e.currentVersion ? `v${e.currentVersion}` : <span className="dim">unpublished</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{e.seriesName || <span className="dim"></span>}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>{when(e.updatedAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{/* Start is admin only and the button follows the route: an
editor sees the definition and cannot commit the
deployment to running it. */}
{isAdmin && e.state === 'ready' && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
disabled={busy} onClick={() => startNow(e)}>
Start now
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>
Recent runs
{live.length > 0 && <span className="dim" style={{ fontWeight: 400 }}> · {live.length} in flight</span>}
</h3>
{runs.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing has run yet.</p>
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Occurrence</th>
<th className="adm-th">Event</th>
<th className="adm-th">Status</th>
<th className="adm-th">Phase</th>
<th className="adm-th">Health</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
<Link to={`/admin/events/runs/${r.id}`}>{when(r.scheduledFor)}</Link>
{r.rehearsal && <span className="dim" style={{ fontSize: '0.74rem' }}> · rehearsal</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.definitionTitle} <span className="dim">v{r.version}</span>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
{runStatusWord(r.status)}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.currentPhase || <span className="dim"></span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: HEALTH_COLOR[r.health] || undefined }}>
{r.health === 'ok' ? <span className="dim">ok</span> : r.health}
</td>
<td className="adm-td" style={{ textAlign: 'right', fontSize: '0.78rem' }}>
{r.waitingSteps > 0 && (
<span style={{ color: '#d9c184' }}>
waiting on {r.waitingSteps === 1 ? 'a person' : `${r.waitingSteps} people`}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,310 @@
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 actions 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 runners 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')
})