feat(events): the Event System — core (Phase 16b cutover, 2 of 6) #199
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
296
client/src/lib/eventAuthoring.js
Normal file
296
client/src/lib/eventAuthoring.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
505
client/src/routes/admin/views/EventEditor.jsx
Normal file
505
client/src/routes/admin/views/EventEditor.jsx
Normal 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’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’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 “phase 3 has not started” 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>
|
||||
)
|
||||
}
|
||||
352
client/src/routes/admin/views/EventRun.jsx
Normal file
352
client/src/routes/admin/views/EventRun.jsx
Normal 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 & 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 “degraded” 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 & 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 & 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’s own diagnostic record, newest first — this is what answers “why didn’t phase 3
|
||||
start?” 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>
|
||||
)
|
||||
}
|
||||
260
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
260
client/src/routes/admin/views/EventsAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
310
client/test/eventAuthoring.test.js
Normal file
310
client/test/eventAuthoring.test.js
Normal 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 action’s declared examples', () => {
|
||||
const fresh = blankStep(ANNOUNCE)
|
||||
assert.equal(fresh.actionId, 'core.announce')
|
||||
assert.deepEqual(JSON.parse(fresh.paramsText), {
|
||||
leg: 'discord',
|
||||
title: 'The gates open',
|
||||
body: 'A caravan was sighted.',
|
||||
})
|
||||
})
|
||||
|
||||
test('a new phase never collides with an existing key', () => {
|
||||
// Two phases sharing a key would silently collapse at materialisation —
|
||||
// `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored
|
||||
// steps would never exist. The server refuses it; the form must not propose it.
|
||||
const first = blankPhase([])
|
||||
const second = blankPhase([first])
|
||||
const third = blankPhase([first, second])
|
||||
assert.equal(new Set([first.key, second.key, third.key]).size, 3)
|
||||
})
|
||||
|
||||
test('the form round-trips a definition without losing a step', () => {
|
||||
const event = {
|
||||
title: 'Invasion',
|
||||
graceSeconds: 600,
|
||||
timezone: 'Europe/Berlin',
|
||||
concurrencyKey: 'invasion:{region}',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{
|
||||
key: 'warn',
|
||||
label: 'Warning',
|
||||
steps: [
|
||||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const built = payloadFromForm(formFromDefinition(event))
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.phases, [
|
||||
{
|
||||
key: 'warn',
|
||||
label: 'Warning',
|
||||
steps: [
|
||||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||||
],
|
||||
},
|
||||
])
|
||||
assert.equal(built.payload.graceSeconds, 600)
|
||||
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
|
||||
})
|
||||
|
||||
test('an unchosen onFailure is omitted rather than invented', () => {
|
||||
// The server defaults it from the action's risk class, which is the whole
|
||||
// reason `risk` is required at registration. A form that posted a value would
|
||||
// silently override that — turning a `change` action's `pause` into a `skip`
|
||||
// and advancing a run over a half-changed world.
|
||||
const form = formFromDefinition({
|
||||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false)
|
||||
})
|
||||
|
||||
test('a params box that is not JSON is refused with the step named', () => {
|
||||
const form = formFromDefinition({
|
||||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||||
})
|
||||
form.phases[0].steps[0].paramsText = '{ leg: discord }'
|
||||
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, false)
|
||||
assert.match(built.errors[0], /Phase 1 "Main", step 1/)
|
||||
})
|
||||
|
||||
test('an empty params box is an empty object, not an error', () => {
|
||||
assert.deepEqual(parseParams('').params, {})
|
||||
assert.deepEqual(parseParams(' ').params, {})
|
||||
assert.ok(parseParams('[1,2]').error, 'an array is not a params object')
|
||||
assert.ok(parseParams('"leg"').error)
|
||||
})
|
||||
|
||||
// ── Rendering what happened ────────────────────────────────────────────────
|
||||
|
||||
test('a human transition reads differently from the runner’s own', () => {
|
||||
// Both are `run.status` rows. `detail.control` is the only thing that separates
|
||||
// "the runner paused this because a world write failed" from "somebody pressed
|
||||
// pause", and the console has to tell them apart at a glance.
|
||||
const byRunner = describeLogLine({
|
||||
kind: 'run.status',
|
||||
detail: { from: 'running', to: 'paused', because: 'core.spawn' },
|
||||
})
|
||||
const byPerson = describeLogLine({
|
||||
kind: 'run.status',
|
||||
detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' },
|
||||
})
|
||||
|
||||
assert.match(byRunner, /Running → Paused/)
|
||||
assert.match(byRunner, /core\.spawn/)
|
||||
assert.match(byPerson, /pause/)
|
||||
assert.match(byPerson, /by staff/)
|
||||
assert.match(byPerson, /shard is lagging/)
|
||||
})
|
||||
|
||||
test('the log lines a run produces all render as something', () => {
|
||||
const lines = [
|
||||
{ kind: 'run.created', detail: { version: 3, rehearsal: true } },
|
||||
{ kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } },
|
||||
{ kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } },
|
||||
{ kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } },
|
||||
{ kind: 'phase.completed', phase: 'warn', detail: {} },
|
||||
{ kind: 'step.parked', detail: { action: 'core.cue' } },
|
||||
{ kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } },
|
||||
{ kind: 'step.status', detail: { action: 'core.wait', to: 'done' } },
|
||||
{ kind: 'note', detail: {} },
|
||||
]
|
||||
for (const line of lines) {
|
||||
const text = describeLogLine(line)
|
||||
assert.equal(typeof text, 'string')
|
||||
assert.ok(text.length > 0, `${line.kind} rendered as nothing`)
|
||||
assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
|
||||
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.ok(runStatusWord(s).length > 0)
|
||||
}
|
||||
assert.equal(runStatusWord('something-new'), 'something-new')
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
296
server/src/model/events/eventRunControls.model.js
Normal file
296
server/src/model/events/eventRunControls.model.js
Normal 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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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` })
|
||||
|
||||
@@ -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": [
|
||||
|
||||
442
server/test/eventRunControls.test.js
Normal file
442
server/test/eventRunControls.test.js
Normal 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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 definition’s 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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user