feat(events): the Event System — core (Phase 16b cutover, 2 of 6) #199

Merged
whitlocktech merged 35 commits from edge into main 2026-09-10 00:43:49 +00:00
20 changed files with 3775 additions and 8 deletions
Showing only changes of commit a481248bc0 - Show all commits

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

View File

@@ -518,6 +518,15 @@
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/cancel",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log",
@@ -527,6 +536,51 @@
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/pause",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/resume",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/series",

View File

@@ -229,10 +229,34 @@
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/cancel"
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/pause"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/resume"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip"
},
{
"method": "GET",
"path": "/api/v1/admin/events/series"

View File

@@ -0,0 +1,296 @@
// ── The live run controls ──────────────────────────────────────────────────
//
// EVENTS.md §I ("live controls that are honest"), §K and §L. Six of them: pause,
// resume and cancel act on a run; confirm, skip and retry act on one step. They
// arrive in Phase 3 because Phase 2 is what gave them something to act on — a
// run that announces, waits and completes on its own is exactly the run that
// needs no control, and a run that paused on a failed world write is the one
// that does.
//
// **Two of §I's six run-level controls are deliberately not here.**
// `advance` — force a phase forward — has no honest meaning yet: a phase today
// advances when its steps go terminal, and the per-step skip already does that
// one step at a time. Phase 5 is what gives a phase an `advance` CONDITION, and
// that is the first moment "force it anyway" means something an operator could
// predict. `cleanup` needs Phase 8's resource ledger; there is nothing to
// revert, so cancel takes `{ reason }` and gains `cleanup` when there is
// something for it to do. Both are absent rather than inert, which is the
// posture Phase 1 set and Phase 2 kept.
//
// **Every control is guarded on the status it may act from, and the guard is a
// WHERE clause rather than a read-then-write.** A run console rendered thirty
// seconds ago describes a run that has since moved — the runner ticks every
// fifteen — so a control that checked in JavaScript and then wrote would race
// the tick it exists to interrupt. `transition()` and the four step statements
// are all compare-and-set, and a `false` from one of them is reported as a 409
// naming the status the run is actually in.
//
// **Who may press them is `admin` + `moderator` (§K, §N2), and it is the widest
// gate in this feature on purpose.** Starting a run commits the deployment to
// everything the definition contains, unattended — that wants the narrowest gate
// there is. Stopping one is incident response at 2am, and it wants the widest.
const runsDb = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const MAX_REASON = 500
const clean = (raw) => {
const text = typeof raw === 'string' ? raw.trim() : ''
return text ? text.slice(0, MAX_REASON) : null
}
const conflict = (message) => ({ ok: false, status: 409, errors: [message] })
/** The run, or a 404 shaped the way every other model here shapes one. */
async function loadRun(runId) {
const run = await runsDb.getById(runId)
return run || null
}
/**
* A step of THIS run, or null.
*
* Scoped to the run rather than fetched by id alone: the step id arrives from a
* URL under a run id, and a control that acted on a step belonging to a
* different run would be a real one — the console's step ids are not secret and
* the two paths would otherwise never be compared.
*/
async function loadStep(runId, stepId) {
const step = await stepsDb.getById(stepId)
if (!step || Number(step.run_id) !== Number(runId)) return null
return step
}
// ── Run-level ─────────────────────────────────────────────────────────────
/**
* Pause a run in flight.
*
* `starting` and `running` only — §K's "live control of a run **in flight**". A
* `scheduled` run has not begun, and the thing to do with an occurrence that
* should not happen is cancel it: pausing one would leave a run that is neither
* going to start nor visibly abandoned, and resuming it after its grace window
* had passed would produce a `missed` from a button labelled resume.
*
* The claim is cleared with the transition. A tick may be working the run at
* this exact moment; it will find its guarded writes returning zero rows and
* hand back a lease it no longer holds, both of which are no-ops. What it will
* NOT do is dispatch the rest of its batch — `advanceRun` re-reads the status
* between steps precisely so this control means what it says.
*/
async function pause(runId, { reason } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status === 'paused') return conflict('this run is already paused')
const note = clean(reason)
if (!(await runsDb.transition(run.id, ['starting', 'running'], 'paused', { clearClaim: true }))) {
return conflict(`a ${run.status} run cannot be paused`)
}
await logDb.write({
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
detail: { from: run.status, to: 'paused', control: 'pause', by: userId, reason: note },
})
return { ok: true, run: await runsDb.getById(run.id) }
}
/**
* Resume a paused run.
*
* Where it goes back to is derived rather than remembered: `current_phase` is
* set by the transition into `running` and by nothing else, so a paused run that
* has one was running and a paused run that has none never got past `starting`.
* Both statuses are in `findDue`, so the next tick picks the run up either way,
* and there is no fourth column recording what a run was paused *from* — a
* column that could disagree with the run's own history.
*
* **`last_error` is cleared and `health` is not.** The error is what the pause
* was about and an operator has just dealt with it; leaving it on the banner
* would have a healthy run permanently accused of a failure that is in the log
* where it belongs. Health is a different claim — that this run has already had
* trouble — and it stays true no matter who pressed resume.
*/
async function resume(runId, options = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status !== 'paused') return conflict(`a ${run.status} run is not paused`)
const to = run.current_phase ? 'running' : 'starting'
if (!(await runsDb.transition(run.id, 'paused', to, { error: null }))) {
return conflict('this run stopped being paused')
}
await logDb.write({
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
detail: { from: 'paused', to, control: 'resume', by: userId },
})
return { ok: true, run: await runsDb.getById(run.id) }
}
/**
* Cancel a run.
*
* Legal from every non-terminal status including `scheduled`, because "this
* event is not happening" is a decision an operator makes before it starts as
* often as during it.
*
* `cancelOpen` then closes out the steps that will never run — the pending ones
* and any parked cue. A step with a LIVE lease is left exactly where it is:
* something is dispatching it, nothing can recall a command already sent (§L),
* and a second writer on that row would race the process that owns it. It
* finishes into a cancelled run, which is honest.
*/
async function cancel(runId, { reason } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is already ${run.status}`)
const note = clean(reason)
const from = ['scheduled', 'starting', 'running', 'paused', 'ending']
if (!(await runsDb.transition(run.id, from, 'cancelled', { error: note || 'cancelled by staff' }))) {
return conflict('this run is no longer cancellable')
}
const closed = await stepsDb.cancelOpen(run.id)
await logDb.write({
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
detail: { from: run.status, to: 'cancelled', control: 'cancel', by: userId, reason: note, cancelledSteps: closed },
})
return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
}
// ── Step-level ────────────────────────────────────────────────────────────
/**
* Confirm a parked step — the GM cue's other half.
*
* `core.cue` posts an instruction and parks: the step stays `running` with a
* NULL lease, genuinely in flight with nothing holding it, so no sweep takes it
* back and a cue posted on Friday is still waiting on Monday. This is what ends
* it, and it is the control that makes the whole system useful before any module
* automates anything — a GM does the target-driven part in-client and says so
* here.
*
* The outcome is `done`, not `skipped`: a person saying they did the thing is
* the step having succeeded. The note is what they did, and it is kept.
*/
async function confirmStep(runId, stepId, { note } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
const step = await loadStep(runId, stepId)
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
const text = clean(note)
if (!(await stepsDb.confirmParked(step.id, text))) {
return conflict(`this step is ${step.status} and is not waiting on anyone`)
}
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.status',
phase: step.phase,
detail: { to: 'done', action: step.action_id, control: 'confirm', by: userId, note: text },
})
return { ok: true, step: await stepsDb.getById(step.id) }
}
/**
* Skip a step: one that has not started, or a parked cue nobody is going to do.
*
* This is what the `skipped` status was reserved for (§L) — which is also why
* the three `on_failure` dispositions all write `failed` instead. A status
* meaning both "a human decided against this" and "this was attempted three
* times and never worked" would make the console's summary line unreadable.
*
* A `failed` step is not skippable and does not need to be: `nextOpenStep`
* already passes over one, so resuming a run carries the phase past it.
*/
async function skipStep(runId, stepId, { reason } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is ${run.status}`)
const step = await loadStep(runId, stepId)
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
const note = clean(reason)
if (!(await stepsDb.skipByHuman(step.id, note))) {
return conflict(`a ${step.status} step cannot be skipped`)
}
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.status',
phase: step.phase,
detail: { to: 'skipped', action: step.action_id, control: 'skip', by: userId, reason: note },
})
return { ok: true, step: await stepsDb.getById(step.id) }
}
/**
* Re-queue the failed step a run is stopped at, and resume the run — one action.
*
* **The two halves are one control because there is no state in which you would
* want half of it.** Retry is legal only from `paused`, and a paused run is
* paused *at* this step; re-queueing without resuming would leave the run in
* precisely the state it was already in, with a button the operator now has to
* find. Splitting them would read as honesty and behave as a trap.
*
* Two guards, and the second is the one worth explaining. The step must be the
* furthest one its phase has reached — `lastStartedSeq` — because a `failed`
* step under an `on_failure` of `skip` is one the run has already moved PAST.
* `nextOpenStep` selects `pending` and `running` only, so the runner steps over
* a failed row; re-queueing an earlier one puts a `pending` step behind the
* cursor, where it sits for ever.
*/
async function retryStep(runId, stepId, options = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status !== 'paused') {
return conflict(`a step can only be retried while its run is paused; this run is ${run.status}`)
}
const step = await loadStep(runId, stepId)
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
if (step.status !== 'failed') return conflict(`a ${step.status} step cannot be retried`)
if (step.phase !== run.current_phase) {
return conflict('this step belongs to a phase the run has already left')
}
const furthest = await stepsDb.lastStartedSeq(run.id, step.phase)
if (furthest === null || Number(furthest) !== Number(step.seq)) {
return conflict('the run is not stopped at this step; only the step a phase is stopped at can be retried')
}
if (!(await stepsDb.requeue(step.id))) return conflict('this step is no longer failed')
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.status',
phase: step.phase,
detail: { to: 'pending', action: step.action_id, control: 'retry', by: userId, attemptsReset: step.attempts },
})
const resumed = await resume(runId, {}, userId)
return {
ok: true,
step: await stepsDb.getById(step.id),
// A resume that did not take is reported rather than swallowed: the step IS
// re-queued either way, and an operator told "retried" about a run that is
// still paused would be told something false.
resumed: Boolean(resumed.ok),
run: resumed.run || (await runsDb.getById(run.id)),
}
}
module.exports = { pause, resume, cancel, confirmStep, skipStep, retryStep }

View File

@@ -274,6 +274,130 @@ const cancelPending = async (runId) => {
return Number(result?.affectedRows || 0)
}
// ── Phase 3: the controls a human works ────────────────────────────────────
//
// Four statements, and every one of them is guarded on the status it is allowed
// to act from rather than trusting the button that was pressed. The run console
// decides what to OFFER; these decide what may happen, and they disagree on
// purpose — a console rendered thirty seconds ago is a console describing a run
// that has since moved.
//
// **A parked step is `running` with a NULL lease**, and that pair is the whole
// vocabulary these need. `park()` above is the only thing that produces it, so
// `status = 'running' AND claim_expires_at IS NULL` names a cue waiting on a
// human and cannot name a step some process is mid-dispatch on. Confirm and skip
// are both written against it, which is what makes them safe to expose to a
// moderator: neither can touch a step the runner is holding.
/**
* The highest `seq` of a step in this phase that is not still `pending` — the
* furthest the phase has got — or null if none of it has been attempted.
*
* It exists for the retry control, and the definition is chosen to agree with
* the runner's own cursor rather than to look tidy. Steps within a phase are
* strictly serial, so the last step that is not pending is the last one the
* runner worked on; if the run is `paused` that step is what it paused at.
*
* **The near miss worth recording: "the lowest step that is not settled" is the
* wrong rule**, and it looks right. `nextOpenStep` selects `pending` and
* `running` only, so a `failed` step is one the runner has already stepped OVER
* — which is exactly what an `on_failure` of `skip` produces. Under that rule a
* phase whose second step failed-and-skipped and whose fifth then failed-and-
* paused would offer retry on the second, re-queueing a row behind the runner's
* cursor where it would sit pending for ever.
*/
const lastStartedSeq = async (runId, phase) => {
const [row] = await query(
`SELECT MAX(seq) AS seq FROM event_run_steps
WHERE run_id = ? AND phase = ? AND status <> 'pending'`,
[runId, phase],
)
return row?.seq === null || row?.seq === undefined ? null : Number(row.seq)
}
/**
* Resolve a parked step: the GM cue's confirm.
*
* `done` rather than `skipped` — a human saying they did the thing is the step
* having succeeded, and it is the only outcome under which the instruction was
* actually carried out. The note is kept in `last_error` for the same reason the
* park's is: it is the column the console already renders beside the step, and a
* second one for prose would be a column two writers disagree about.
*/
const confirmParked = async (id, note) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'done', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`,
[note ? String(note).slice(0, 500) : null, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Skip a step a human has decided not to run: `pending`, or a parked cue.
*
* This is what `skipped` was reserved for (§L). A `running` step with a live
* lease is excluded — nothing can recall a command already sent — and a `failed`
* one is excluded because it is already terminal and the run's own resume is
* what carries the phase past it.
*/
const skipByHuman = async (id, reason) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
[reason ? String(reason).slice(0, 500) : null, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Put a failed step back in the queue for another attempt.
*
* **`attempts` goes back to zero, and that is not the rule Engagement Phase 14
* arrived at being broken.** That rule is about SWEEPS: an automatic path that
* reset a counter made the ceiling unreachable and the row immortal. This is a
* named person deciding, once, that the thing which failed three times will work
* now — `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does unattended, and a
* human is the thing it is unattended from. The decision is in the run log with
* the actor on it.
*/
const requeue = async (id) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
WHERE id = ? AND status = 'failed'`,
[id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Close out every step a cancelled run will never run: pending, and parked.
*
* Wider than `cancelPending` by exactly one case, and deliberately so. §L leaves
* a `running` step alone because nothing can recall a sent command — but a
* parked cue is not a sent command, it is an instruction nobody is holding, and
* leaving it `running` after the run was cancelled would leave the console
* claiming a cancelled event is still waiting for someone. The live lease is
* what distinguishes them, and it is in the WHERE clause.
*/
const cancelOpen = async (runId) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
WHERE run_id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
[runId],
)
return Number(result?.affectedRows || 0)
}
module.exports = {
listForRun,
listForPhase,
@@ -289,4 +413,9 @@ module.exports = {
holdNext,
reclaimStale,
cancelPending,
lastStartedSeq,
confirmParked,
skipByHuman,
requeue,
cancelOpen,
}

View File

@@ -23,8 +23,17 @@ const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), r
// only if every path a run can take reaches one of them.
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
// pair `park()` alone produces, which means a cue waiting on a human. It is a
// correlated subquery on an admin list bounded at 500 rows rather than a column,
// because it is derived from the steps and a column would be a second writer's
// opinion of them. It earns its cost on the list screen: a cue nobody notices is
// a run that never advances, and the run itself looks perfectly healthy until
// somebody opens it.
const SELECT_LIST = `
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number,
(SELECT COUNT(*) FROM event_run_steps s
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
FROM event_runs r
JOIN event_definitions d ON d.id = r.definition_id
JOIN event_versions v ON v.id = r.version_id
@@ -241,6 +250,20 @@ async function transition(id, from, to, { phase, error, clearClaim = false } = {
return Number(result?.affectedRows || 0) === 1
}
/**
* Just this run's status, for a caller that must not act on a stale read.
*
* The runner drains a bounded batch of steps from one run inside a single tick,
* and Phase 3 put a pause and a cancel button in a human's hand — so between two
* steps of that batch the run may have stopped. A loop that only re-checked at
* the top of the tick would answer a pause by dispatching another two dozen
* steps, which is not a pause. One column, by primary key.
*/
const statusOf = async (id) => {
const [row] = await query('SELECT status FROM event_runs WHERE id = ?', [id])
return row?.status || null
}
/**
* Set health without touching status (§E).
*
@@ -354,6 +377,7 @@ module.exports = {
claimStart,
claimTick,
releaseClaim,
statusOf,
transition,
setHealth,
concurrencyHolder,

View File

@@ -8,10 +8,14 @@
// a definition arriving from a future import or a restore gets the same answer
// this screen does.
//
// **What is deliberately absent**: pause, resume, advance, cancel, step
// skip/retry/confirm, cleanup and the action switchboard. Each of them acts on a
// run in flight, and nothing is in flight until Phase 2 builds the runner. A
// control that returns 200 and does nothing is worse than one that is not there.
// **Phase 3 added the live run controls** at the bottom of this file: pause,
// resume, cancel, and a step's confirm, skip and retry. What is still absent is
// `advance`, `cleanup` and the action switchboard — `advance` has no honest
// meaning until Phase 5 gives a phase an advance condition, `cleanup` has no
// ledger to work over until Phase 8, and the switchboard is Phase 6's. Each of
// them is absent rather than stubbed, for the reason the whole set was in Phase
// 1: a control that returns 200 and does nothing is worse than one that is not
// there.
const registries = require('../../../modules/registries')
const spec = require('../../../events/spec')
@@ -21,6 +25,7 @@ const versionsDb = require('../../../model/events/eventVersions.db')
const seriesDb = require('../../../model/events/eventSeries.db')
const runsDb = require('../../../model/events/eventRuns.db')
const runs = require('../../../model/events/eventRuns.model')
const controls = require('../../../model/events/eventRunControls.model')
const logDb = require('../../../model/events/eventRunLog.db')
const activity = require('../../../model/activity/activity.model')
@@ -80,6 +85,10 @@ const shapeRun = (r) => ({
endedAt: r.ended_at,
lastError: r.last_error,
createdAt: r.created_at,
// How many steps are parked on a human. Derived, not a column, and surfaced on
// the LIST as well as the console because a cue nobody notices is a run that
// never advances while looking perfectly healthy from the outside.
waitingSteps: Number(r.waiting_steps || 0),
})
const shapeStep = (s) => ({
@@ -91,6 +100,12 @@ const shapeStep = (s) => ({
params: s.params,
actionVersion: s.action_version,
status: s.status,
// `running` with no lease is a parked step (§E) — waiting on a human, with
// nothing holding it. The console has to tell that apart from a step some
// process is mid-dispatch on, and it must not do so by being shown the lease:
// one derived boolean rather than `claimed_by` and `claim_expires_at`, which
// are the runner's business and would invite a UI that reasoned about leases.
parked: s.status === 'running' && !s.claim_expires_at,
dueAt: s.due_at,
attempts: s.attempts,
onFailure: s.on_failure,
@@ -313,3 +328,89 @@ exports.startRun = async (req, res) => {
created: result.created,
})
}
// ── Phase 3: the live run controls ─────────────────────────────────────────
//
// Six handlers, and each is the same four lines: read the ids out of the URL,
// hand off to `eventRunControls`, log the manual transition to `activity_log`,
// answer with the row. Every guard is in the model, where a control invoked from
// anywhere else gets the same answer — which is the same division this file has
// had since Phase 1.
//
// **The audit is written in two places on purpose, and they are not redundant.**
// `event_run_log` is the run's own diagnostic record: queryable by phase and by
// step, and it is what the console renders. `activity_log` is the deployment's
// record of what staff did, and it is where "who cancelled the invasion" is
// looked up months later by somebody who is not looking at that run. §J names
// both.
/** POST /api/v1/admin/events/runs/:runId/pause */
exports.pauseRun = async (req, res) => {
const runId = asId(req.params.runId)
if (!runId) return res.status(400).json({ error: 'bad run id' })
const result = await controls.pause(runId, { reason: req.body?.reason }, req.user.id)
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
await activity.log({ req, action: 'event.run.paused', detail: { runId, reason: req.body?.reason || null } })
return res.json({ run: shapeRun(result.run) })
}
/** POST /api/v1/admin/events/runs/:runId/resume */
exports.resumeRun = async (req, res) => {
const runId = asId(req.params.runId)
if (!runId) return res.status(400).json({ error: 'bad run id' })
const result = await controls.resume(runId, {}, req.user.id)
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
await activity.log({ req, action: 'event.run.resumed', detail: { runId } })
return res.json({ run: shapeRun(result.run) })
}
/** POST /api/v1/admin/events/runs/:runId/cancel */
exports.cancelRun = async (req, res) => {
const runId = asId(req.params.runId)
if (!runId) return res.status(400).json({ error: 'bad run id' })
const result = await controls.cancel(runId, { reason: req.body?.reason }, req.user.id)
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
await activity.log({
req,
action: 'event.run.cancelled',
detail: { runId, reason: req.body?.reason || null, cancelledSteps: result.cancelledSteps },
})
return res.json({ run: shapeRun(result.run), cancelledSteps: result.cancelledSteps })
}
/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/confirm */
exports.confirmStep = async (req, res) => {
const runId = asId(req.params.runId)
const stepId = asId(req.params.stepId)
if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
const result = await controls.confirmStep(runId, stepId, { note: req.body?.note }, req.user.id)
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
await activity.log({ req, action: 'event.step.confirmed', detail: { runId, stepId, action: result.step.action_id } })
return res.json({ step: shapeStep(result.step) })
}
/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/skip */
exports.skipStep = async (req, res) => {
const runId = asId(req.params.runId)
const stepId = asId(req.params.stepId)
if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
const result = await controls.skipStep(runId, stepId, { reason: req.body?.reason }, req.user.id)
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
await activity.log({
req,
action: 'event.step.skipped',
detail: { runId, stepId, action: result.step.action_id, reason: req.body?.reason || null },
})
return res.json({ step: shapeStep(result.step) })
}
/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/retry */
exports.retryStep = async (req, res) => {
const runId = asId(req.params.runId)
const stepId = asId(req.params.stepId)
if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
const result = await controls.retryStep(runId, stepId, {}, req.user.id)
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
await activity.log({ req, action: 'event.step.retried', detail: { runId, stepId, action: result.step.action_id } })
return res.json({ step: shapeStep(result.step), run: shapeRun(result.run), resumed: result.resumed })
}

View File

@@ -12,9 +12,11 @@
// are here anyway, because a button that is admin-only later and open now is a
// gate nobody notices was missing.
//
// Reads are staff-wide. `verify` (admin, editor) is Phase 6's, and the live run
// controls (admin, moderator) are Phase 3's — neither is stubbed here, because
// nothing is in flight until Phase 2 builds the runner.
// Reads are staff-wide. The live run controls landed in Phase 3 and are `admin`
// + `moderator`, deliberately wider than start (§N2). `verify` (admin, editor),
// `advance`, `cleanup` and the action switchboard are still absent rather than
// stubbed — there is no advance condition until Phase 5, no resource ledger
// until Phase 8 and no caps to price against until Phase 6.
//
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and
// `/runs` are never read as an event id.
@@ -27,6 +29,10 @@ const { requireRole } = require('../../../utils/auth')
const eventsRouter = express.Router()
const adminOnly = requireRole('admin')
const adminOrEditor = requireRole('admin', 'editor')
// Live control of a run in flight, and the one gate wider than `admin` in this
// feature (§K). Named rather than inlined so the six routes below cannot drift
// apart from one another.
const liveControl = requireRole('admin', 'moderator')
// ── The catalog and the vocabularies, served from the registries ───────────
@@ -93,6 +99,105 @@ eventsRouter.get(
controller.getRunLog,
)
// ── The live run controls (Phase 3) ───────────────────────────────────────
//
// `admin` + `moderator`, and it is the widest gate in this feature deliberately
// (§K, §N2). Starting a run commits the deployment to everything the definition
// contains, unattended, up to every cap it declares — that wants the narrowest
// gate there is. Stopping one is incident response, and the incident is "the
// event is doing something wrong at 2am" — that wants the widest. A split that
// read consistent, with one role owning both buttons, would behave badly in
// exactly the case the moderator role exists for.
//
// `advance` and `cleanup` from the § API surface table are not here: the first
// has no honest meaning until Phase 5 gives a phase an advance condition, the
// second has no resource ledger to work over until Phase 8.
eventsRouter.post(
'/runs/:runId/pause',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Pause a run in flight'
// #swagger.description = 'A paused run is excluded from the runner\'s sweep and nothing advances it until resume. Legal from `starting` and `running` only — a `scheduled` occurrence that should not happen is cancelled, not paused, because resuming one after its grace window had passed would produce a `missed` from a button labelled resume. Takes effect at once even mid-tick: the runner re-reads the run\'s status between steps.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Recorded in the run log with the actor" } } } } } } */
/* #swagger.responses[200] = { description: 'The paused run', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[409] = { description: 'The run is not in flight', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
liveControl,
controller.pauseRun,
)
eventsRouter.post(
'/runs/:runId/resume',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Resume a paused run'
// #swagger.description = 'Where the run goes back to is derived rather than remembered: a paused run with a `current_phase` was running, one without never got past `starting`. `last_error` is cleared — the operator has just dealt with it — and `health` is not, because "this run has already had trouble" stays true whoever pressed resume.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The resumed run', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[409] = { description: 'The run is not paused', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
liveControl,
controller.resumeRun,
)
eventsRouter.post(
'/runs/:runId/cancel',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Cancel a run'
// #swagger.description = 'Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` is not a parameter yet — the resource ledger it would work over arrives in Phase 8, and a flag that changes nothing is worse than one that is not there.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Why. Recorded on the run and in its log, with the actor." } } } } } } */
/* #swagger.responses[200] = { description: 'The cancelled run and how many steps were closed out with it', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, cancelledSteps: { type: "integer" } } } } } } */
/* #swagger.responses[409] = { description: 'The run has already reached a terminal status', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
liveControl,
controller.cancelRun,
)
eventsRouter.post(
'/runs/:runId/steps/:stepId/confirm',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Confirm a parked step — the GM cue'
// #swagger.description = 'The other half of `core.cue`. The action posts an instruction and parks the step `running` with a NULL lease — genuinely in flight, nothing holding it, so no sweep takes it back and a cue posted on Friday is still waiting on Monday. This ends it, as `done` rather than `skipped`: a person saying they did the thing is the step having succeeded. The optional note is what they did, and it is kept on the step and in the log.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { note: { type: "string", description: "What was actually done in-client" } } } } } } */
/* #swagger.responses[200] = { description: 'The confirmed step', content: { "application/json": { schema: { type: "object", properties: { step: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[404] = { description: 'No such run, or no such step on it', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The step is not waiting on anyone', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
liveControl,
controller.confirmStep,
)
eventsRouter.post(
'/runs/:runId/steps/:stepId/skip',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Skip a step nobody is going to run'
// #swagger.description = 'A step that has not started, or a parked cue. This is what the `skipped` status was reserved for, and why all three `on_failure` dispositions write `failed` instead — a status meaning both "a human decided against this" and "this was attempted three times and never worked" would make the console summary unreadable. A step with a live lease cannot be skipped; a failed one does not need to be, because resuming the run already carries the phase past it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'The skipped step', content: { "application/json": { schema: { type: "object", properties: { step: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[404] = { description: 'No such run, or no such step on it', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The step or its run is in a status that cannot be skipped', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
liveControl,
controller.skipStep,
)
eventsRouter.post(
'/runs/:runId/steps/:stepId/retry',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Re-queue the failed step a paused run is stopped at, and resume it'
// #swagger.description = 'One action rather than two, because there is no state in which you would want half of it: retry is legal only while the run is paused, and a paused run is paused AT this step. The step must be the one its phase is stopped at — a failed step under an `on_failure` of `skip` is one the run has already moved past, and re-queueing that would put a pending row behind the runner\'s cursor. `attempts` returns to zero: the attempt ceiling bounds what the runner does unattended, and a named person deciding is the thing it is unattended from.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The re-queued step and the run, with whether the resume took', content: { "application/json": { schema: { type: "object", properties: { step: { type: "object", additionalProperties: true }, run: { type: "object", additionalProperties: true }, resumed: { type: "boolean" } } } } } } */
/* #swagger.responses[404] = { description: 'No such run, or no such step on it', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The run is not paused, or the run is not stopped at this step', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
liveControl,
controller.retryStep,
)
// ── Definitions ───────────────────────────────────────────────────────────
eventsRouter.get(

View File

@@ -292,6 +292,14 @@ async function advanceRun(run, now) {
const carry = {}
for (let n = 0; n < STEPS_PER_TICK; n++) {
// Re-read the run's status between steps, not just at the top of the tick.
// This loop drains up to STEPS_PER_TICK steps from one run, and Phase 3 put
// a pause and a cancel in a human's hand: without this, pausing a run in the
// middle of a batch would answer by dispatching another two dozen steps,
// which is not a pause. One indexed column read per step, against a control
// whose entire value is that it takes effect at once.
if (n > 0 && (await runsDb.statusOf(run.id)) !== 'running') return 'stopped'
const phaseIndex = phases.findIndex((p) => p.key === phaseKey)
if (phaseIndex < 0) {
await runsDb.transition(run.id, ['running'], 'failed', { error: `phase "${phaseKey}" is not in the pinned version` })

View File

@@ -3766,6 +3766,101 @@
]
}
},
"/api/v1/admin/events/runs/{runId}/cancel": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Cancel a run",
"description": "Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` is not a parameter yet — the resource ledger it would work over arrives in Phase 8, and a flag that changes nothing is worse than one that is not there.",
"parameters": [
{
"name": "runId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The cancelled run and how many steps were closed out with it",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"run": {
"type": "object",
"additionalProperties": true
},
"cancelledSteps": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Not an admin or moderator",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The run has already reached a terminal status",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Why. Recorded on the run and in its log, with the actor."
}
}
}
}
}
}
}
},
"/api/v1/admin/events/runs/{runId}/log": {
"get": {
"tags": [
@@ -3842,6 +3937,494 @@
]
}
},
"/api/v1/admin/events/runs/{runId}/pause": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Pause a run in flight",
"description": "A paused run is excluded from the runner\\'s sweep and nothing advances it until resume. Legal from `starting` and `running` only — a `scheduled` occurrence that should not happen is cancelled, not paused, because resuming one after its grace window had passed would produce a `missed` from a button labelled resume. Takes effect at once even mid-tick: the runner re-reads the run\\'s status between steps.",
"parameters": [
{
"name": "runId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The paused run",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"run": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Not an admin or moderator",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The run is not in flight",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Recorded in the run log with the actor"
}
}
}
}
}
}
}
},
"/api/v1/admin/events/runs/{runId}/resume": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Resume a paused run",
"description": "Where the run goes back to is derived rather than remembered: a paused run with a `current_phase` was running, one without never got past `starting`. `last_error` is cleared — the operator has just dealt with it — and `health` is not, because \"this run has already had trouble\" stays true whoever pressed resume.",
"parameters": [
{
"name": "runId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The resumed run",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"run": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Not an admin or moderator",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The run is not paused",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/events/runs/{runId}/steps/{stepId}/confirm": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Confirm a parked step — the GM cue",
"description": "The other half of `core.cue`. The action posts an instruction and parks the step `running` with a NULL lease — genuinely in flight, nothing holding it, so no sweep takes it back and a cue posted on Friday is still waiting on Monday. This ends it, as `done` rather than `skipped`: a person saying they did the thing is the step having succeeded. The optional note is what they did, and it is kept on the step and in the log.",
"parameters": [
{
"name": "runId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "stepId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The confirmed step",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"step": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Not an admin or moderator",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such run, or no such step on it",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The step is not waiting on anyone",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"note": {
"type": "string",
"description": "What was actually done in-client"
}
}
}
}
}
}
}
},
"/api/v1/admin/events/runs/{runId}/steps/{stepId}/retry": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Re-queue the failed step a paused run is stopped at, and resume it",
"description": "One action rather than two, because there is no state in which you would want half of it: retry is legal only while the run is paused, and a paused run is paused AT this step. The step must be the one its phase is stopped at — a failed step under an `on_failure` of `skip` is one the run has already moved past, and re-queueing that would put a pending row behind the runner\\'s cursor. `attempts` returns to zero: the attempt ceiling bounds what the runner does unattended, and a named person deciding is the thing it is unattended from.",
"parameters": [
{
"name": "runId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "stepId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The re-queued step and the run, with whether the resume took",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"step": {
"type": "object",
"additionalProperties": true
},
"run": {
"type": "object",
"additionalProperties": true
},
"resumed": {
"type": "boolean"
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Not an admin or moderator",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such run, or no such step on it",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The run is not paused, or the run is not stopped at this step",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/events/runs/{runId}/steps/{stepId}/skip": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Skip a step nobody is going to run",
"description": "A step that has not started, or a parked cue. This is what the `skipped` status was reserved for, and why all three `on_failure` dispositions write `failed` instead — a status meaning both \"a human decided against this\" and \"this was attempted three times and never worked\" would make the console summary unreadable. A step with a live lease cannot be skipped; a failed one does not need to be, because resuming the run already carries the phase past it.",
"parameters": [
{
"name": "runId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "stepId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The skipped step",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"step": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Not an admin or moderator",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such run, or no such step on it",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The step or its run is in a status that cannot be skipped",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"reason": {
"type": "string"
}
}
}
}
}
}
}
},
"/api/v1/admin/events/series": {
"get": {
"tags": [

View File

@@ -0,0 +1,442 @@
// ── The live run controls (EVENTS_PLAN.md Phase 3) ─────────────────────────
//
// Six controls, and what is tested is almost entirely the REFUSALS. A control
// that works is easy; a control that works from a status it should not have
// worked from is a staff member changing a live game world by pressing a button
// a stale screen offered them. So each of the six is exercised from every status
// it must decline, and the four that a run console could plausibly offer wrongly
// get a test of their own:
//
// • retry on a step the run has already moved past (the `skip` disposition) —
// the test that found the first draft's guard was reading the wrong end of
// the phase
// • confirm on a step a process is mid-dispatch on, not a parked cue
// • skip on a step with a live lease
// • cancel closing out a parked cue, so a cancelled run stops "waiting"
//
// The three tables are stubbed at the `.db` layer and the model's own logic runs
// for real against them — the shape `eventRunner.test.js` uses. What a stub
// cannot prove is that the five statements mean this against a real server; the
// guards that are pure SQL (`status = 'running' AND claim_expires_at IS NULL`
// and `lastStartedSeq`'s MAX) are proved in `eventRunnerSql.test.js`.
//
// Point the DB at a closed port before requiring anything.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const controls = require('../src/model/events/eventRunControls.model')
const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
const db = require('../src/utils/db')
after(() => db.close())
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
const ACTOR = 7
let store
const originals = [
['runs', runsDb, { ...runsDb }],
['steps', stepsDb, { ...stepsDb }],
['log', logDb, { ...logDb }],
]
function installStubs() {
store = { runs: new Map(), steps: new Map(), log: [], nextStepId: 1 }
const snap = (o) => ({ ...o })
runsDb.getById = async (id) => {
const r = store.runs.get(Number(id))
return r ? snap(r) : null
}
runsDb.transition = async (id, from, to, opts = {}) => {
const r = store.runs.get(Number(id))
const froms = Array.isArray(from) ? from : [from]
if (!r || !froms.includes(r.status)) return false
r.status = to
if (opts.phase !== undefined) r.current_phase = opts.phase
if (opts.error !== undefined) r.last_error = opts.error
if (TERMINAL.includes(to) || opts.clearClaim) {
r.claimed_by = null
r.claim_expires_at = null
}
return true
}
stepsDb.getById = async (id) => {
const s = store.steps.get(Number(id))
return s ? snap(s) : null
}
// Each of the four mirrors its statement's WHERE clause exactly. A stub can
// only ever agree with whoever wrote it, so what these buy is the model's
// logic around them; the clauses themselves are checked against a real server.
stepsDb.confirmParked = async (id, note) => {
const s = store.steps.get(Number(id))
if (!s || s.status !== 'running' || s.claim_expires_at) return false
Object.assign(s, { status: 'done', last_error: note, claimed_by: null })
return true
}
stepsDb.skipByHuman = async (id, reason) => {
const s = store.steps.get(Number(id))
if (!s) return false
const ok = s.status === 'pending' || (s.status === 'running' && !s.claim_expires_at)
if (!ok) return false
Object.assign(s, { status: 'skipped', last_error: reason, claimed_by: null })
return true
}
stepsDb.requeue = async (id) => {
const s = store.steps.get(Number(id))
if (!s || s.status !== 'failed') return false
Object.assign(s, { status: 'pending', attempts: 0, due_at: null, last_error: null, claimed_by: null, claim_expires_at: null })
return true
}
stepsDb.cancelOpen = async (runId) => {
let n = 0
for (const s of store.steps.values()) {
if (s.run_id !== Number(runId)) continue
if (s.status === 'pending' || (s.status === 'running' && !s.claim_expires_at)) {
s.status = 'cancelled'
n += 1
}
}
return n
}
stepsDb.lastStartedSeq = async (runId, phase) => {
const started = [...store.steps.values()]
.filter((s) => s.run_id === Number(runId) && s.phase === phase && s.status !== 'pending')
.map((s) => s.seq)
return started.length ? Math.max(...started) : null
}
logDb.write = async (line) => {
store.log.push(line)
return true
}
}
beforeEach(installStubs)
afterEach(() => {
for (const [, mod, fns] of originals) Object.assign(mod, fns)
})
let nextRunId = 1
function seedRun({ status = 'running', phase = 'main', steps = [] } = {}) {
const id = nextRunId++
store.runs.set(id, {
id,
definition_id: id,
version_id: id,
status,
health: 'ok',
current_phase: phase,
claimed_by: null,
claim_expires_at: null,
last_error: null,
})
steps.forEach((s, i) => {
const stepId = store.nextStepId++
store.steps.set(stepId, {
id: stepId,
run_id: id,
phase: s.phase || phase,
seq: s.seq ?? i,
action_id: s.actionId || 'test.action',
status: s.status || 'pending',
attempts: s.attempts ?? 0,
due_at: null,
claimed_by: s.leased ? 'someone' : null,
claim_expires_at: s.leased ? new Date(Date.now() + 60_000) : null,
last_error: null,
params: {},
})
})
return id
}
const runRow = (id) => store.runs.get(id)
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
const lastLog = () => store.log[store.log.length - 1]
// ── pause / resume ─────────────────────────────────────────────────────────
test('pause takes a run in flight and records who did it', async () => {
const id = seedRun({ status: 'running' })
const result = await controls.pause(id, { reason: 'the shard is lagging' }, ACTOR)
assert.equal(result.ok, true)
assert.equal(runRow(id).status, 'paused')
assert.deepEqual(lastLog().detail, {
from: 'running',
to: 'paused',
control: 'pause',
by: ACTOR,
reason: 'the shard is lagging',
})
})
test('pause drops the claim, so the next tick is not locked out of a resumed run', async () => {
const id = seedRun({ status: 'running' })
Object.assign(runRow(id), { claimed_by: 'host:1', claim_expires_at: new Date(Date.now() + 900_000) })
await controls.pause(id, {}, ACTOR)
assert.equal(runRow(id).claimed_by, null)
assert.equal(runRow(id).claim_expires_at, null)
})
test('a scheduled run cannot be paused — it is cancelled instead', async () => {
// Pausing one would leave a run that is neither going to start nor visibly
// abandoned, and resuming it after its grace window had passed would produce a
// `missed` from a button labelled resume.
const id = seedRun({ status: 'scheduled' })
const result = await controls.pause(id, {}, ACTOR)
assert.equal(result.ok, false)
assert.equal(result.status, 409)
assert.match(result.errors[0], /scheduled/)
assert.equal(runRow(id).status, 'scheduled')
})
test('a completed run cannot be paused', async () => {
const id = seedRun({ status: 'completed' })
assert.equal((await controls.pause(id, {}, ACTOR)).ok, false)
})
test('resume returns a run to running, or to starting when it never entered a phase', async () => {
const withPhase = seedRun({ status: 'paused', phase: 'main' })
assert.equal((await controls.resume(withPhase, {}, ACTOR)).ok, true)
assert.equal(runRow(withPhase).status, 'running')
const beforePhase = seedRun({ status: 'paused', phase: null })
assert.equal((await controls.resume(beforePhase, {}, ACTOR)).ok, true)
assert.equal(runRow(beforePhase).status, 'starting', 'both are in findDue; neither is a fourth column')
})
test('resume clears the error it was paused over and leaves health alone', async () => {
const id = seedRun({ status: 'paused' })
Object.assign(runRow(id), { last_error: 'core.spawn failed', health: 'degraded' })
await controls.resume(id, {}, ACTOR)
assert.equal(runRow(id).last_error, null, 'a resolved failure must not accuse a healthy run for ever')
assert.equal(runRow(id).health, 'degraded', 'that this run has already had trouble stays true')
})
test('resume refuses a run that is not paused', async () => {
const id = seedRun({ status: 'running' })
const result = await controls.resume(id, {}, ACTOR)
assert.equal(result.ok, false)
assert.equal(result.status, 409)
})
// ── cancel ─────────────────────────────────────────────────────────────────
test('cancel closes out the pending steps and the parked cue, and leaves a leased step alone', async () => {
const id = seedRun({
status: 'running',
steps: [
{ status: 'done' },
{ status: 'running', leased: true }, // mid-dispatch: nothing can recall a sent command
{ status: 'running' }, // parked on a human: nothing is holding it
{ status: 'pending' },
],
})
const result = await controls.cancel(id, { reason: 'called off' }, ACTOR)
assert.equal(result.ok, true)
assert.equal(runRow(id).status, 'cancelled')
assert.equal(result.cancelledSteps, 2)
const [done, leased, parked, pending] = stepsOf(id)
assert.equal(done.status, 'done')
assert.equal(leased.status, 'running', 'a step being dispatched is not touched')
assert.equal(parked.status, 'cancelled', 'a cancelled run must stop claiming to wait on somebody')
assert.equal(pending.status, 'cancelled')
})
test('cancel is legal before a run has started', async () => {
const id = seedRun({ status: 'scheduled', steps: [{ status: 'pending' }] })
assert.equal((await controls.cancel(id, {}, ACTOR)).ok, true)
assert.equal(runRow(id).status, 'cancelled')
})
test('cancel refuses a run that is already terminal', async () => {
for (const status of TERMINAL) {
const id = seedRun({ status })
const result = await controls.cancel(id, {}, ACTOR)
assert.equal(result.ok, false, `${status} should not be cancellable`)
assert.match(result.errors[0], new RegExp(status))
}
})
// ── confirm ────────────────────────────────────────────────────────────────
test('confirm resolves a parked cue as done, keeping what the person says they did', async () => {
const id = seedRun({ status: 'running', steps: [{ status: 'running', actionId: 'core.cue' }] })
const [cue] = stepsOf(id)
const result = await controls.confirmStep(id, cue.id, { note: 'gate opened, herald read' }, ACTOR)
assert.equal(result.ok, true)
assert.equal(stepsOf(id)[0].status, 'done', 'a person saying they did it is the step having succeeded')
assert.equal(stepsOf(id)[0].last_error, 'gate opened, herald read')
assert.equal(lastLog().detail.control, 'confirm')
assert.equal(lastLog().detail.by, ACTOR)
})
test('confirm cannot resolve a step a process is dispatching', async () => {
// The whole vocabulary here is "running with a NULL lease". A live lease means
// something is mid-dispatch, and confirming it would race the process that
// owns the row.
const id = seedRun({ status: 'running', steps: [{ status: 'running', leased: true }] })
const [busy] = stepsOf(id)
const result = await controls.confirmStep(id, busy.id, {}, ACTOR)
assert.equal(result.ok, false)
assert.equal(result.status, 409)
assert.equal(stepsOf(id)[0].status, 'running')
})
test('a step id from another run is a 404, not an action', async () => {
const mine = seedRun({ status: 'running', steps: [{ status: 'pending' }] })
const theirs = seedRun({ status: 'running', steps: [{ status: 'running' }] })
const [theirStep] = stepsOf(theirs)
const result = await controls.confirmStep(mine, theirStep.id, {}, ACTOR)
assert.equal(result.ok, false)
assert.equal(result.status, 404)
assert.equal(stepsOf(theirs)[0].status, 'running')
})
// ── skip ───────────────────────────────────────────────────────────────────
test('skip takes a pending step and a parked cue, and nothing else', async () => {
const id = seedRun({
status: 'running',
steps: [{ status: 'pending' }, { status: 'running' }, { status: 'running', leased: true }, { status: 'failed' }],
})
const [pending, parked, leased, failed] = stepsOf(id)
assert.equal((await controls.skipStep(id, pending.id, {}, ACTOR)).ok, true)
assert.equal((await controls.skipStep(id, parked.id, {}, ACTOR)).ok, true)
assert.equal((await controls.skipStep(id, leased.id, {}, ACTOR)).ok, false)
// A failed step does not need skipping: `nextOpenStep` already passes over it,
// so resuming the run carries the phase past it.
assert.equal((await controls.skipStep(id, failed.id, {}, ACTOR)).ok, false)
const after = stepsOf(id)
assert.equal(after[0].status, 'skipped')
assert.equal(after[1].status, 'skipped')
assert.equal(after[2].status, 'running')
assert.equal(after[3].status, 'failed')
})
test('skip refuses once the run is over', async () => {
const id = seedRun({ status: 'completed', steps: [{ status: 'pending' }] })
const [step] = stepsOf(id)
assert.equal((await controls.skipStep(id, step.id, {}, ACTOR)).ok, false)
})
// ── retry ──────────────────────────────────────────────────────────────────
test('retry re-queues the step a paused run is stopped at, and resumes in the same action', async () => {
const id = seedRun({
status: 'paused',
steps: [{ status: 'done' }, { status: 'failed', attempts: 3 }, { status: 'pending' }],
})
const failed = stepsOf(id)[1]
const result = await controls.retryStep(id, failed.id, {}, ACTOR)
assert.equal(result.ok, true)
assert.equal(result.resumed, true)
assert.equal(stepsOf(id)[1].status, 'pending')
assert.equal(stepsOf(id)[1].attempts, 0, 'the ceiling bounds the runner, not a person deciding once')
assert.equal(runRow(id).status, 'running', 'there is no state in which you would want half of this')
})
test('retry refuses a step the run has already moved past', async () => {
// The case the guard exists for: a failed step under an `on_failure` of `skip`
// is one the phase carried on from. Re-queueing it would put a pending row
// behind the runner's cursor, where it would sit for ever.
const id = seedRun({
status: 'paused',
steps: [{ status: 'failed', attempts: 3 }, { status: 'done' }, { status: 'failed', attempts: 3 }],
})
const [movedPast] = stepsOf(id)
const result = await controls.retryStep(id, movedPast.id, {}, ACTOR)
assert.equal(result.ok, false)
assert.equal(result.status, 409)
assert.match(result.errors[0], /stopped at this step/)
assert.equal(stepsOf(id)[0].status, 'failed')
assert.equal(runRow(id).status, 'paused', 'a refused retry does not resume the run either')
})
test('retry refuses a step in a phase the run has left', async () => {
const id = seedRun({
status: 'paused',
phase: 'two',
steps: [{ phase: 'one', seq: 0, status: 'failed' }, { phase: 'two', seq: 0, status: 'pending' }],
})
const [old] = stepsOf(id)
const result = await controls.retryStep(id, old.id, {}, ACTOR)
assert.equal(result.ok, false)
assert.match(result.errors[0], /already left/)
})
test('retry refuses while the run is still running', async () => {
const id = seedRun({ status: 'running', steps: [{ status: 'failed' }] })
const [failed] = stepsOf(id)
const result = await controls.retryStep(id, failed.id, {}, ACTOR)
assert.equal(result.ok, false)
assert.match(result.errors[0], /paused/)
})
test('retry refuses a step that is not failed', async () => {
const id = seedRun({ status: 'paused', steps: [{ status: 'pending' }] })
const [pending] = stepsOf(id)
assert.equal((await controls.retryStep(id, pending.id, {}, ACTOR)).ok, false)
})
// ── the record ─────────────────────────────────────────────────────────────
test('every control writes one log line carrying the actor and the control name', async () => {
const id = seedRun({ status: 'running', steps: [{ status: 'running' }, { status: 'pending' }] })
const [parked, pending] = stepsOf(id)
await controls.confirmStep(id, parked.id, { note: 'done' }, ACTOR)
await controls.skipStep(id, pending.id, { reason: 'not needed' }, ACTOR)
await controls.pause(id, {}, ACTOR)
await controls.resume(id, {}, ACTOR)
await controls.cancel(id, { reason: 'over' }, ACTOR)
const human = store.log.filter((l) => l.detail?.control)
assert.deepEqual(human.map((l) => l.detail.control), ['confirm', 'skip', 'pause', 'resume', 'cancel'])
assert.ok(human.every((l) => l.detail.by === ACTOR))
// The kinds are the ones a reader already scans for. A human transition is
// still a transition; `detail.control` is what separates it from the runner's.
assert.deepEqual([...new Set(human.map((l) => l.kind))].sort(), ['run.status', 'step.status'])
})
test('an empty reason is stored as NULL rather than as an empty string', async () => {
const id = seedRun({ status: 'running' })
await controls.pause(id, { reason: ' ' }, ACTOR)
assert.equal(lastLog().detail.reason, null)
})

View File

@@ -131,6 +131,8 @@ function installStubs() {
return true
}
runsDb.statusOf = async (id) => store.runs.get(id)?.status || null
runsDb.setHealth = async (id, health) => {
const r = store.runs.get(id)
if (!r || r.health === health) return false
@@ -687,3 +689,51 @@ test('the dispatch envelope carries what §F says it carries', async () => {
assert.equal(envelope.idempotencyKey.length, 40)
assert.deepEqual(Object.keys(envelope).sort(), ['actor', 'idempotencyKey', 'params', 'runId', 'scope', 'stepId', 'verify'])
})
test('a run paused mid-batch stops there rather than draining the rest of the phase', async () => {
// The whole value of a pause is that it takes effect NOW. `advanceRun` drains
// up to STEPS_PER_TICK steps from one run inside a single tick, so a status
// re-read only at the top of the tick would answer a pause by dispatching
// another two dozen steps. Written by pausing from inside an action's own
// `perform`, which is the only moment that race is reproducible.
register([
scriptedAction('test.pauser', {
perform: async ({ runId }) => {
await runsDb.transition(runId, ['starting', 'running'], 'paused', { clearClaim: true })
return { ok: true }
},
}),
scriptedAction('test.after'),
])
const id = seedRun([
{
key: 'main',
label: 'Main',
steps: [step('test.pauser'), step('test.after'), step('test.after')],
},
])
await runner.tick(T0)
assert.equal(run(id).status, 'paused')
const [first, second, third] = stepsOf(id)
assert.equal(first.status, 'done', 'the step that was already dispatched finishes')
assert.equal(second.status, 'pending', 'nothing after it ran')
assert.equal(third.status, 'pending')
assert.equal(scripted['test.after'], undefined, 'the later action was never called')
})
test('a resumed run picks up from the step it stopped at', async () => {
register([scriptedAction('test.a'), scriptedAction('test.b')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.a'), step('test.b')] }])
await runner.tick(T0)
assert.equal(run(id).status, 'completed')
// And the mirror of it: a run parked at `paused` is not picked up at all, which
// is what `findDue`'s omission of the status buys.
const other = seedRun([{ key: 'main', label: 'Main', steps: [step('test.a')] }], { status: 'paused' })
await runner.tick(T0)
assert.equal(stepsOf(other)[0].status, 'pending', 'a paused run is not swept')
})

View File

@@ -25,6 +25,19 @@
// both a MariaDB-specific syntax and a correctness claim: it must move the
// next PENDING step and only ever push a due date later.
//
// **Phase 3 added four more**, and each of them is a control a staff member
// presses against a live game world:
//
// * **`confirmParked` / `skipByHuman`** - both keyed on `status = 'running'
// AND claim_expires_at IS NULL`. That pair, and only that pair, means "a cue
// waiting on a human". If the clause let a LEASED step through, confirm would
// race the process mid-dispatch on that row.
// * **`cancelOpen`** - pending steps and parked cues, never a leased one.
// * **`lastStartedSeq`** - a `MAX(seq) ... WHERE status <> 'pending'`, which is
// what decides whether retry is offered. The first draft asked for the LOWEST
// unsettled seq instead, which is a different step whenever a phase carried
// on past an `on_failure: skip` failure.
//
// Plus the two unique indexes that are load-bearing rather than tidy:
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
@@ -150,6 +163,36 @@ UPDATE event_run_steps
AND (due_at IS NULL OR due_at < ?)
ORDER BY seq LIMIT 1`
// Phase 3's four, verbatim from `eventRunSteps.db.js`.
const CONFIRM_PARKED = `
UPDATE event_run_steps
SET status = 'done', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`
const SKIP_BY_HUMAN = `
UPDATE event_run_steps
SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
const REQUEUE = `
UPDATE event_run_steps
SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
WHERE id = ? AND status = 'failed'`
const CANCEL_OPEN = `
UPDATE event_run_steps
SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
WHERE run_id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
const LAST_STARTED_SEQ = `
SELECT MAX(seq) AS seq FROM event_run_steps
WHERE run_id = ? AND phase = ? AND status <> 'pending'`
const MATERIALISE_RUN = `
INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
VALUES (?, ?, ?, ?, ?)`
@@ -506,3 +549,113 @@ test('findMissed compares against each definitions own grace window', async (
assert.deepEqual(missed, [tight.runId])
assert.ok(!missed.includes(generous.runId), 'inside its own window a run starts late rather than being missed')
})
// -- Phase 3: the controls a human presses ----------------------------------
test('confirm resolves a parked cue and cannot touch a step being dispatched', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const parked = await seedStep(runId, { seq: 0, status: 'running', claimedBy: 'host:1', claimExpiresAt: null })
const busy = await seedStep(runId, { seq: 1, status: 'running', claimedBy: 'host:1', claimExpiresAt: later(60_000), key: 'x'.repeat(40) })
assert.equal(rows(await pool.query(CONFIRM_PARKED, ['gate opened', parked])), 1)
assert.equal(rows(await pool.query(CONFIRM_PARKED, ['nope', busy])), 0, 'a live lease is a step somebody owns')
assert.equal((await stepById(parked)).status, 'done')
assert.equal((await stepById(parked)).last_error, 'gate opened')
assert.equal((await stepById(busy)).status, 'running')
})
test('a confirm of an already-confirmed cue reports 0, not 1', async (t) => {
if (needDb(t)) return
// The engagement Phase 4a shape: a connector that defaults `foundRows: true`
// reports 1 for an UPDATE that matched and changed nothing, and a control that
// read that as success would tell a second staff member their press worked.
const { runId } = await seedRun({ status: 'running' })
const parked = await seedStep(runId, { status: 'running', claimExpiresAt: null })
assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 1)
assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 0)
})
test('skip takes a pending step and a parked cue, and refuses a leased one', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const pending = await seedStep(runId, { seq: 0, status: 'pending' })
const parked = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: null, key: 'y'.repeat(40) })
const busy = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: later(60_000), key: 'z'.repeat(40) })
const failed = await seedStep(runId, { seq: 3, status: 'failed', key: 'w'.repeat(40) })
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, pending])), 1)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, parked])), 1)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, busy])), 0)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, failed])), 0, 'a failed step is terminal; resume carries the phase past it')
})
test('cancelOpen closes pending steps and parked cues, and leaves a leased one alone', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const done = await seedStep(runId, { seq: 0, status: 'done' })
const busy = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: later(60_000), key: 'p'.repeat(40) })
const parked = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: null, key: 'q'.repeat(40) })
const pending = await seedStep(runId, { seq: 3, status: 'pending', key: 'r'.repeat(40) })
assert.equal(rows(await pool.query(CANCEL_OPEN, [runId])), 2)
assert.equal((await stepById(done)).status, 'done')
assert.equal((await stepById(busy)).status, 'running', 'nothing can recall a command already sent')
assert.equal((await stepById(parked)).status, 'cancelled', 'a cancelled run must stop claiming to wait on somebody')
assert.equal((await stepById(pending)).status, 'cancelled')
})
test('requeue only takes a failed step, and puts attempts back to zero', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'paused' })
const failed = await seedStep(runId, { seq: 0, status: 'failed', attempts: 3 })
const pending = await seedStep(runId, { seq: 1, status: 'pending', key: 's'.repeat(40) })
assert.equal(rows(await pool.query(REQUEUE, [failed])), 1)
assert.equal(rows(await pool.query(REQUEUE, [pending])), 0)
const row = await stepById(failed)
assert.equal(row.status, 'pending')
assert.equal(Number(row.attempts), 0)
assert.equal(row.due_at, null, 'a re-queued step is due now, not at the retry backoff it was left on')
})
test('lastStartedSeq names the furthest step of the phase, not the earliest unsettled one', async (t) => {
if (needDb(t)) return
// The defect this replaced: a phase that carried on past a failed step (an
// `on_failure` of `skip`) and then paused at a later one. "The lowest seq that
// is not settled" answers with the FIRST failure - a step the runner has long
// since stepped over - and retry would re-queue a row behind its own cursor.
const { runId } = await seedRun({ status: 'paused' })
await seedStep(runId, { seq: 0, status: 'failed', key: 'a'.repeat(40) })
await seedStep(runId, { seq: 1, status: 'done', key: 'b'.repeat(40) })
await seedStep(runId, { seq: 2, status: 'failed', key: 'c'.repeat(40) })
await seedStep(runId, { seq: 3, status: 'pending', key: 'd'.repeat(40) })
const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
assert.equal(Number(row.seq), 2)
})
test('lastStartedSeq is NULL for a phase nothing has touched', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
await seedStep(runId, { seq: 0, status: 'pending' })
const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
assert.equal(row.seq, null, 'a null must read as "nothing to retry", not as seq 0')
})
test('a guarded transition refuses a run that was cancelled underneath it', async (t) => {
if (needDb(t)) return
// What the admin cancel looks like from the runner's side, mid-tick: the
// guarded write returns 0 and the tick treats the run as taken rather than
// advancing a run somebody has just stopped.
const { runId } = await seedRun({ status: 'running' })
await pool.query("UPDATE event_runs SET status = 'cancelled' WHERE id = ?", [runId])
assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0)
assert.equal((await runById(runId)).status, 'cancelled')
})