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

@@ -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>
)
}