Compare commits
55 Commits
feature/en
...
feat/publi
| Author | SHA1 | Date | |
|---|---|---|---|
| b5616f359c | |||
| 702ab89ae2 | |||
| 9e1f591b25 | |||
| efa9db7330 | |||
| 720103e3d4 | |||
| f373f2e897 | |||
| 61dc692088 | |||
| 655fbf3f69 | |||
| baa4f7d5ba | |||
| 7d7840eb6b | |||
| b92b85c3a9 | |||
| 6dd4e5e3eb | |||
| af9f4e191c | |||
| e46842a28c | |||
| 2e9ed50e21 | |||
| eb167558e3 | |||
| 1667e636bd | |||
| 6e6c24065c | |||
| 8453762e3b | |||
| db8e01e868 | |||
| 37f4623068 | |||
| d0178c6419 | |||
| 809426ad73 | |||
| aba8d1e43a | |||
| 7d3d6d5abd | |||
| d4516739b4 | |||
| 82a50e5e04 | |||
| 4a91d74085 | |||
| fdc118166c | |||
| 57d183e921 | |||
| fd9fb50351 | |||
| 429e657239 | |||
| 4077c4e79e | |||
| 4ac917c3a3 | |||
| 9bc0bf5a3d | |||
| 9c23c5fd0e | |||
| 6e73660b52 | |||
| a481248bc0 | |||
| 7b570c8ea1 | |||
| 2ba397eff7 | |||
| 2e964cfeee | |||
| d88906e43c | |||
| 8e03497eb3 | |||
| 6331b36c45 | |||
| 5779d15150 | |||
| e59a68c152 | |||
| eec7dbf785 | |||
| 66bb3b9a3f | |||
| 52eac24d17 | |||
| c8d45733b6 | |||
| c3783f56f1 | |||
| 40ab1ce8d2 | |||
| 0a9149a04f | |||
| cfd1cb3c3c | |||
| 81e0338a69 |
@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
|
||||
import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Events from './routes/public/Events.jsx'
|
||||
import EventPage from './routes/public/EventPage.jsx'
|
||||
import EventSeries from './routes/public/EventSeries.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
@@ -48,6 +51,12 @@ import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
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 EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
|
||||
import EventEditor from './routes/admin/views/EventEditor.jsx'
|
||||
import EventRun from './routes/admin/views/EventRun.jsx'
|
||||
import EventActions from './routes/admin/views/EventActions.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'
|
||||
@@ -68,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import PlayerInbox from './routes/player/PlayerInbox.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
import PlayerEvents from './routes/player/PlayerEvents.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -101,6 +111,17 @@ export default function App() {
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
|
||||
although it could not be shadowed by it — two segments against
|
||||
one. It stays above because the ranking surprise this feature
|
||||
has already shipped once was exactly here: a static segment
|
||||
outranks a dynamic one whatever the source order, which is what
|
||||
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
|
||||
Nothing static shares a segment with `:slug`, so nothing here
|
||||
repeats it. */}
|
||||
<Route path="/site/events" element={<Events />} />
|
||||
<Route path="/site/events/series/:slug" element={<EventSeries />} />
|
||||
<Route path="/site/events/:slug" element={<EventPage />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
@@ -191,6 +212,27 @@ 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/calendar" element={<EventsCalendar />} />
|
||||
{/* The switchboard (Phase 6). A literal segment, declared before
|
||||
`events/:id` the way the router declares `/actions` before
|
||||
`/:id` — the same collision, on the other side of the wire. */}
|
||||
<Route path="events/actions" element={<EventActions />} />
|
||||
<Route path="events/runs/:runId" element={<EventRun />} />
|
||||
{/* ONE route, and `new` is a value of `:id` rather than a
|
||||
path beside it. A static `events/new` outranks the dynamic
|
||||
segment in React Router whatever the order, so the editor
|
||||
was handed no `id` at all and asked the API for
|
||||
`/admin/events/undefined`. */}
|
||||
<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
|
||||
@@ -210,6 +252,7 @@ export default function App() {
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
<Route path="suppressions" element={<EngagementSuppressions />} />
|
||||
<Route path="retention" element={<EngagementRetention />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Staff have an inbox and channel preferences like anyone else —
|
||||
@@ -219,6 +262,15 @@ export default function App() {
|
||||
two paths; `lib/notificationPaths.js` is the one mapping. */}
|
||||
<Route path="notifications" element={<PlayerInbox />} />
|
||||
<Route path="notifications/settings" element={<PlayerNotifications />} />
|
||||
{/* And participation history, for the same reason and by the same
|
||||
arrangement (Phase 14a): `/player/events/history` is behind
|
||||
requireAuth alone, so a staff member has one — but
|
||||
`RequirePlayer` sends them out of `/account`. Declared BEFORE
|
||||
`events/:id`, though it need not be: a static segment outranks
|
||||
a dynamic one whatever the order, which is the rule that made
|
||||
`events/new` unreachable for seven phases. Written in the order
|
||||
it resolves. */}
|
||||
<Route path="events/mine" element={<PlayerEvents />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
wrapper — only an optional { roles }, which core applies as the
|
||||
@@ -262,6 +314,11 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* Participation history (Phase 14a). Under /account rather than
|
||||
/player because it is role-agnostic self-service: staff are a
|
||||
superset of players and an admin reading their own attendance
|
||||
is as ordinary as anyone else doing it. */}
|
||||
<Route path="/account/events" element={<PlayerEvents />} />
|
||||
{/* The inbox took `/account/notifications` in engagement Phase 7
|
||||
and the preferences screen moved under it. Content and
|
||||
settings are different kinds of thing, and the plain word
|
||||
|
||||
@@ -256,6 +256,24 @@ export const api = {
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
// ----- Events (EVENTS.md § API surface, Phase 14a) -----
|
||||
//
|
||||
// The anonymous surface. `from`/`to` are optional — the server defaults to now
|
||||
// through a month out, so the calendar's first render need not compute a window
|
||||
// before it can ask for anything.
|
||||
publicEvents: ({ from, to, seriesId } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (from) qs.set('from', from)
|
||||
if (to) qs.set('to', to)
|
||||
if (seriesId) qs.set('seriesId', String(seriesId))
|
||||
return req(`/public/events${withQs(qs.toString())}`)
|
||||
},
|
||||
// `run` is what an announcement's link carries, so a mail about last Friday's
|
||||
// occurrence opens last Friday's results rather than next Friday's.
|
||||
publicEvent: (slug, run = null) =>
|
||||
req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`),
|
||||
publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`),
|
||||
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -437,8 +455,14 @@ export const api = {
|
||||
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
|
||||
// of a DELETE rather than in the path, and that is not style: a path
|
||||
// parameter lands in the access log, the browser history and every proxy in
|
||||
// front of the deployment, and this one is a real person's address. The list
|
||||
// never returns a hash to use instead.
|
||||
// front of the deployment, and this one is a real person's address.
|
||||
//
|
||||
// **Phase 14 added the second form, and it is the one the row uses.** The
|
||||
// list now returns each row's `address_hash`, so the Lift button on a row
|
||||
// needs no address at all — the operator is looking at a mask and has never
|
||||
// been told the address. `unsuppressAddress` stays for the address the
|
||||
// operator types, which is the only way to reach a row that is not on the
|
||||
// page in front of them.
|
||||
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
@@ -452,6 +476,113 @@ export const api = {
|
||||
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
|
||||
unsuppressAddress: (address, channel) =>
|
||||
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
|
||||
unsuppressByHash: (hash, channel) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (channel) qs.set('channel', channel)
|
||||
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
|
||||
// is not among them because a suppression does not expire.
|
||||
getEngagementRetention: () => req('/admin/engagement/retention'),
|
||||
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'),
|
||||
// Phase 7. The values behind a param's `source` — resolved by the module that
|
||||
// registered the source, on a request of its own rather than inside the
|
||||
// catalog, because a source can be slow or down and must not take the whole
|
||||
// editor with it. A refusal comes back 200 with `ok: false`, so this never
|
||||
// throws for the case the screen is meant to render: the field degrades to
|
||||
// free text with the reason beside it.
|
||||
// Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
|
||||
// ignored, never refused, by a source that does not declare itself
|
||||
// searchable — so passing it is always safe and the field decides whether
|
||||
// it is a typeahead by reading `searchable` off the answer.
|
||||
eventOptions: (sourceId, q) => {
|
||||
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
|
||||
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
|
||||
},
|
||||
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
|
||||
// who wrote the definition is who should be able to price it against the caps
|
||||
// before asking an admin to publish it. A report with findings comes back 200
|
||||
// — the request succeeded, the plan has problems.
|
||||
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
|
||||
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
|
||||
// nothing, so it knows nothing a module knows. It takes the spec in the
|
||||
// body rather than an id because the plan it prices is the one in the
|
||||
// author's hands, which is unsaved between keystrokes, and it records
|
||||
// nothing, which is what makes it safe to call on a debounce.
|
||||
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
|
||||
// The switchboard, admin only in BOTH directions: reading which actions a
|
||||
// deployment permits is as much configuration as writing it (§K). One action
|
||||
// per write rather than the whole board, so an action that appeared between
|
||||
// the read and the write cannot be overwritten with a default.
|
||||
eventActions: () => req('/admin/events/actions'),
|
||||
saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }),
|
||||
eventSeries: () => req('/admin/events/series'),
|
||||
// Series writes are admin+editor rather than admin: naming an arc is
|
||||
// authoring, and §N2's narrow gate is about committing the deployment to a
|
||||
// run. The delete is a real delete and answers with how many definitions it
|
||||
// detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
|
||||
createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
|
||||
updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
|
||||
deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
|
||||
// The calendar. `from`/`to` are UTC instants the caller computes from the
|
||||
// month it is showing, in the READER's zone — the server never guesses it.
|
||||
// A `status` or `scope` filter suppresses projections, which is why the
|
||||
// month view sends neither.
|
||||
eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
|
||||
const qs = new URLSearchParams({ from, to })
|
||||
if (status) qs.set('status', status)
|
||||
if (scope) qs.set('scope', scope)
|
||||
if (seriesId) qs.set('seriesId', String(seriesId))
|
||||
return req(`/admin/events/calendar?${qs.toString()}`)
|
||||
},
|
||||
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' }),
|
||||
// `cleanup` defaults to true server-side and has to be asked out of: EVENTS.md
|
||||
// §L makes cancelling WITHOUT cleanup the separate, admin-only, logged action,
|
||||
// so an absent flag means "give back what this run took".
|
||||
cancelEventRun: (runId, reason, cleanup = true) =>
|
||||
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason, cleanup } }),
|
||||
cleanupEventRun: (runId) => req(`/admin/events/runs/${runId}/cleanup`, { method: 'POST' }),
|
||||
advanceEventRun: (runId, reason) =>
|
||||
req(`/admin/events/runs/${runId}/advance`, { 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
|
||||
@@ -595,6 +726,18 @@ export const api = {
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
|
||||
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
|
||||
|
||||
// ----- event participation (Phase 14a) -----
|
||||
//
|
||||
// Self-scoped on the session and nothing else — there is no id to pass.
|
||||
// `before` is a keyset cursor (the last entry's `id`), not an offset: the
|
||||
// list gains a row every time the reader attends something.
|
||||
eventHistory: ({ limit, before } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (before) qs.set('before', String(before))
|
||||
return req(`/player/events/history${withQs(qs.toString())}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useFeatureGate } from '../modules/features.jsx'
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Events', to: '/site/events' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
|
||||
817
client/src/lib/eventAuthoring.js
Normal file
817
client/src/lib/eventAuthoring.js
Normal file
@@ -0,0 +1,817 @@
|
||||
// ── 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.
|
||||
|
||||
// **The condition builder is borrowed, not rebuilt.** §I says the step editor
|
||||
// reuses "the condition builder, exactly" — and a phase's advance gate is
|
||||
// literally the engagement grammar, validated on the server by
|
||||
// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
|
||||
// from becoming a second opinion about a grammar core owns.
|
||||
import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
|
||||
|
||||
// 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.
|
||||
*
|
||||
* **`advance` is offered only when the phase is genuinely waiting on its gate**,
|
||||
* which is the same test the server makes and is stated here in the same words
|
||||
* on purpose: this decides what is *offered*, the server decides what is
|
||||
* *allowed*, and a button that is present and always refused is the "control
|
||||
* that answers 409 and does nothing" this feature has refused twice. The gate
|
||||
* must be open-and-unsatisfied AND no step of the phase may still be pending or
|
||||
* running — a phase held by a step is held by the step, and skip is its control.
|
||||
*/
|
||||
export function runControlsFor(run, gates = [], steps = []) {
|
||||
if (!run) return { pause: false, resume: false, cancel: false, advance: false }
|
||||
const terminal = isTerminalRun(run.status)
|
||||
const gate = (gates || []).find((g) => g.phase === run.currentPhase)
|
||||
const stepOpen = (steps || []).some(
|
||||
(s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status),
|
||||
)
|
||||
return {
|
||||
pause: ['starting', 'running'].includes(run.status),
|
||||
resume: run.status === 'paused',
|
||||
cancel: !terminal,
|
||||
advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 with the right keys and
|
||||
* plausible values rather than empty. Phase 13 turned the box into a form and
|
||||
* this stayed exactly as it was: a form whose fields start at the declared
|
||||
* example is a step an author edits rather than one they compose.
|
||||
*/
|
||||
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: [], advance: blankAdvance() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The advance gate as the FORM holds it (Phase 5) — three fields that are
|
||||
* always present and mostly empty, rather than a discriminated union the form
|
||||
* has to rebuild every time the dropdown moves.
|
||||
*
|
||||
* `kind: ''` is "no condition", which is what nearly every phase is and what
|
||||
* every phase was before this. The form keeps a half-typed `on` gate's trigger
|
||||
* while the author looks at `after`, because a dropdown that discards what was
|
||||
* typed under the other option is one an operator learns to be afraid of.
|
||||
*/
|
||||
export function blankAdvance() {
|
||||
return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `where` predicate as the BUILDER holds it (Phase 13).
|
||||
*
|
||||
* `whereText` survives beside the rows and is not vestigial: it is what a
|
||||
* predicate the builder cannot render is shown as, and what is posted for one.
|
||||
* See `whereFormFrom`.
|
||||
*/
|
||||
export function blankWhere() {
|
||||
return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' }
|
||||
}
|
||||
|
||||
export const ADVANCE_KINDS = [
|
||||
{ value: '', label: 'When its steps are done' },
|
||||
{ value: 'after', label: 'After a fixed delay' },
|
||||
{ value: 'on', label: 'When something happens in the game' },
|
||||
]
|
||||
|
||||
/** The stored gate, as the form's fields. */
|
||||
export function advanceFormFrom(advance) {
|
||||
const blank = blankAdvance()
|
||||
if (!advance) return blank
|
||||
if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after }
|
||||
return {
|
||||
...blank,
|
||||
kind: 'on',
|
||||
on: advance.on || '',
|
||||
count: advance.count ?? 1,
|
||||
...whereFormFrom(advance.where),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored `where` tree → the builder's flat rows (Phase 13).
|
||||
*
|
||||
* **This is `conditionRowsFrom` and it is deliberately the same function**, not a
|
||||
* second one shaped like it. The grammar behind a phase gate is the engagement
|
||||
* condition grammar — the server validates it with `engagement/conditions.js`
|
||||
* and renders the diagnosis panel's sentence with the same labels — so an editor
|
||||
* here that re-decided what a tree looks like would be the second implementation
|
||||
* §I refuses on the read side for exactly this reason.
|
||||
*
|
||||
* A tree the flat editor cannot hold (`A and (B or C)`) comes back
|
||||
* `whereEditable: false` and is SHOWN as its JSON rather than silently
|
||||
* flattened: `A and B and C` fires on different events, and an author would have
|
||||
* no way to know the save had done it to them.
|
||||
*/
|
||||
export function whereFormFrom(where) {
|
||||
const blank = blankWhere()
|
||||
if (!where) return blank
|
||||
const rows = conditionRowsFrom(where)
|
||||
return {
|
||||
whereOp: rows.op,
|
||||
whereRows: rows.rows,
|
||||
whereEditable: rows.editable,
|
||||
whereText: JSON.stringify(where, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/** 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',
|
||||
// Whether the public calendar announces it (Phase 14a). `?? true` rather
|
||||
// than `|| true`: a definition an operator has deliberately unlisted sends
|
||||
// `false`, and `||` would quietly re-list it on the next save.
|
||||
listed: event?.listed ?? true,
|
||||
// Whether the public calendar announces it (Phase 14a). `?? true` rather
|
||||
// than `|| true`: a definition an operator has deliberately unlisted sends
|
||||
// `false`, and `||` would quietly re-list it on the next save.
|
||||
listed: event?.listed ?? true,
|
||||
...scheduleFormFrom(spec.schedule),
|
||||
phases: (spec.phases || []).map((p) => ({
|
||||
key: p.key || '',
|
||||
label: p.label || '',
|
||||
advance: advanceFormFrom(p.advance),
|
||||
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),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase's advance gate, as the spec shape — or null when it has none.
|
||||
*
|
||||
* **Whether the predicate is VALID is still the server's answer.** The builder
|
||||
* coerces each literal to the type the trigger DECLARED — which is not a second
|
||||
* validator but the thing that makes the first one's error useful: every value
|
||||
* in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
|
||||
* variable is refused by `engagement/conditions.js`, rightly, at which point the
|
||||
* author is reading an error about JSON rather than about what they typed.
|
||||
*
|
||||
* A predicate the builder could not render round-trips through `whereText`
|
||||
* unchanged. That is the point of keeping the text: the alternative to posting it
|
||||
* back verbatim is dropping an author's tree because this screen could not draw
|
||||
* it.
|
||||
*/
|
||||
export function advancePayload(advance, where, errors, variables = []) {
|
||||
if (!advance || !advance.kind) return null
|
||||
if (advance.kind === 'after') return { after: advance.after }
|
||||
|
||||
const out = { on: advance.on, count: Number(advance.count) || 1 }
|
||||
if (advance.whereEditable === false) {
|
||||
const text = String(advance.whereText || '').trim()
|
||||
if (text) {
|
||||
try {
|
||||
out.where = JSON.parse(text)
|
||||
} catch (err) {
|
||||
errors.push(`${where}, advance condition: ${err.message}`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
|
||||
if (built) out.where = built
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, { triggersById = new Map() } = {}) {
|
||||
const errors = []
|
||||
const phases = (form.phases || []).map((phase, pi) => {
|
||||
const where = advancePayload(
|
||||
phase.advance,
|
||||
`Phase ${pi + 1} "${phase.label || phase.key}"`,
|
||||
errors,
|
||||
// The declared types the builder coerces against. A trigger nothing
|
||||
// registers has none, and every literal then stays the string it was typed
|
||||
// as — which is right: the gate is dormant, the server carries its `where`
|
||||
// through unvalidated, and inventing types for it here would edit a
|
||||
// predicate nobody can currently check.
|
||||
triggersById.get(phase.advance?.on)?.variables || [],
|
||||
)
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
// Omitted rather than sent as null when there is no gate, which is what
|
||||
// `events/spec.js` stores for the same reason: a spec full of
|
||||
// `"advance": null` makes the first phase to gain one look like an edit to
|
||||
// every phase in the version diff.
|
||||
...(where ? { advance: where } : {}),
|
||||
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,
|
||||
listed: Boolean(form.listed),
|
||||
listed: Boolean(form.listed),
|
||||
spec: { schedule: scheduleFromForm(form), phases },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── The schedule (Phase 4) ─────────────────────────────────────────────────
|
||||
//
|
||||
// The four closed shapes of §E, mirrored so the form can render one and the
|
||||
// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
|
||||
// the deciders — this is what makes the form a form rather than a text box, and
|
||||
// it is the whole reason the schedule is not a cron string: a closed set has a
|
||||
// dropdown, and an operator can proofread a dropdown.
|
||||
|
||||
export const WEEKDAYS = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
]
|
||||
|
||||
export const SCHEDULE_KINDS = [
|
||||
{ value: 'manual', label: 'Started by hand' },
|
||||
{ value: 'once', label: 'Once, at a set time' },
|
||||
{ value: 'weekly', label: 'Weekly, on chosen days' },
|
||||
{ value: 'monthly', label: 'Monthly, on the nth weekday' },
|
||||
]
|
||||
|
||||
// 1..4 and "last". There is no fifth: every month has a first through fourth of
|
||||
// every weekday, and "last" is what a month with five Fridays makes different
|
||||
// from "fourth" (org lead, 2026-09-02).
|
||||
export const MONTHLY_NTHS = [
|
||||
{ value: 1, label: 'First' },
|
||||
{ value: 2, label: 'Second' },
|
||||
{ value: 3, label: 'Third' },
|
||||
{ value: 4, label: 'Fourth' },
|
||||
{ value: -1, label: 'Last' },
|
||||
]
|
||||
|
||||
const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
|
||||
|
||||
/**
|
||||
* A schedule in words, in the event's own zone.
|
||||
*
|
||||
* The server says the same thing in `events/recurrence.js#describe`, and the two
|
||||
* are allowed to differ on wording but not on meaning — this one is what an
|
||||
* author reads while they are still typing, before anything has been saved.
|
||||
*/
|
||||
export function describeSchedule(schedule, timezone = 'UTC') {
|
||||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||||
const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
|
||||
switch (schedule.kind) {
|
||||
case 'manual':
|
||||
return 'Started by hand — nothing happens until an admin presses Start'
|
||||
case 'once': {
|
||||
if (!schedule.at) return 'Once — no date chosen yet'
|
||||
return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
|
||||
}
|
||||
case 'weekly': {
|
||||
const days = (schedule.days || []).map(capitalise)
|
||||
if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
|
||||
const list =
|
||||
days.length === 1
|
||||
? days[0]
|
||||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||||
return `Every ${list} at ${schedule.time} (${timezone})`
|
||||
}
|
||||
case 'monthly': {
|
||||
if (!nth || !schedule.weekday || !schedule.time) {
|
||||
return 'Monthly — choose a week, a weekday and a time'
|
||||
}
|
||||
return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
|
||||
}
|
||||
default:
|
||||
return 'No schedule'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule half of the editor's working state.
|
||||
*
|
||||
* Every shape's fields are kept side by side rather than cleared when the kind
|
||||
* changes, so an author who clicks Weekly, then Monthly, then back has not lost
|
||||
* the days they picked. `scheduleFromForm` reads only the fields the chosen kind
|
||||
* uses, which is what keeps the request body a clean single shape.
|
||||
*/
|
||||
export function scheduleFormFrom(schedule) {
|
||||
const s = schedule || {}
|
||||
return {
|
||||
scheduleKind: s.kind || 'manual',
|
||||
scheduleAt: s.kind === 'once' ? s.at || '' : '',
|
||||
scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
|
||||
scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
|
||||
scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
|
||||
scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
|
||||
}
|
||||
}
|
||||
|
||||
/** The schedule the form describes, as the spec object the server expects. */
|
||||
export function scheduleFromForm(form) {
|
||||
switch (form.scheduleKind) {
|
||||
case 'once':
|
||||
return { kind: 'once', at: form.scheduleAt }
|
||||
case 'weekly':
|
||||
return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
|
||||
case 'monthly':
|
||||
return {
|
||||
kind: 'monthly',
|
||||
nth: Number(form.scheduleNth),
|
||||
weekday: form.scheduleWeekday,
|
||||
time: form.scheduleTime,
|
||||
}
|
||||
default:
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a calendar entry is, and therefore what may be done with it.
|
||||
*
|
||||
* A `run` is a row: it has a console and somebody can cancel it. A `projected`
|
||||
* entry is arithmetic the runner has not reached yet — there is nothing to open
|
||||
* and nothing to stop, and an operator who treats one as a booking has been
|
||||
* misled by the UI rather than by the server.
|
||||
*/
|
||||
export const isProjected = (entry) => entry?.kind === 'projected'
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
// ── Step params, as a form (Phase 13) ─────────────────────────────
|
||||
//
|
||||
// §I: the step editor is *"the condition builder, exactly — core serves a
|
||||
// catalog, the module declared the schema, core renders a form it does not
|
||||
// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
|
||||
// this, and everything the form needs was already in the catalog: a param's
|
||||
// name, type, whether it is required, its description, its example, and the
|
||||
// option source behind it.
|
||||
//
|
||||
// **The JSON stays as the storage and as the escape hatch, and both halves of
|
||||
// that matter.** As storage, because `payloadFromForm` already builds a request
|
||||
// out of it and a second representation would be two things to keep in step. As
|
||||
// an escape hatch, because a form can only render what the declaration
|
||||
// describes — and a step may legitimately hold something it does not.
|
||||
//
|
||||
// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
|
||||
// the reason this reads as a port of it rather than as a new idea: a value the
|
||||
// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
|
||||
// `A and (B or C)` there and dropping an undeclared param here are the same
|
||||
// mistake — a save that looks clean and means something else.
|
||||
|
||||
/** The two ways a step's params are edited. */
|
||||
export const PARAM_FORM = 'form'
|
||||
export const PARAM_JSON = 'json'
|
||||
|
||||
/**
|
||||
* Can this step's params be rendered as a form without losing anything?
|
||||
*
|
||||
* `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
|
||||
* Three things make one, and none of them is an error — each is a step that has
|
||||
* to be edited as JSON:
|
||||
*
|
||||
* • **the action is dormant.** There is no declaration, so there are no fields.
|
||||
* A form here would render nothing and look like a step with no params.
|
||||
* • **a param the action does not declare.** The save refuses it by name, which
|
||||
* is what the author needs to see — and a form that dropped it would post a
|
||||
* step that saves cleanly having deleted something they typed.
|
||||
* • **a value no single control can hold** — an object or an array against a
|
||||
* scalar declaration.
|
||||
*/
|
||||
export function paramsRenderable(action, params) {
|
||||
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
|
||||
const declared = new Map((action.params || []).map((p) => [p.name, p]))
|
||||
for (const [name, value] of Object.entries(params || {})) {
|
||||
if (!declared.has(name)) {
|
||||
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mode should this step open in?
|
||||
*
|
||||
* The author's own choice wins whenever the form COULD render the step — an
|
||||
* author who switched to JSON stays in JSON. What they cannot do is stay in a
|
||||
* form that would lose something, so an unrenderable step is forced to JSON
|
||||
* whatever the choice was, and the reason is returned so the screen can say it.
|
||||
*/
|
||||
export function paramsMode(step, action) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
|
||||
const renderable = paramsRenderable(action, parsed.params)
|
||||
if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
|
||||
return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
|
||||
}
|
||||
|
||||
/** One declared param's current value, as the control holds it. */
|
||||
export function paramValue(step, name) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return undefined
|
||||
return parsed.params[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one param, and give back the whole box.
|
||||
*
|
||||
* **An empty field REMOVES the key rather than posting an empty string**, and
|
||||
* that is the server's own reading rather than a convenience: `checkParams`
|
||||
* treats `undefined`, `null` and `''` alike — absent — so a required param left
|
||||
* blank comes back as *"is required"*, which is the error the author needs,
|
||||
* instead of as a type complaint about `""`.
|
||||
*
|
||||
* **A value that does not parse is passed through as typed.** `coerceLiteral` is
|
||||
* the engagement builder's, unchanged, and its rule is the one that matters
|
||||
* here too: half of `-` is not a number, and turning it into `NaN` or `0` while
|
||||
* somebody is still typing would either post a value they never wrote or make
|
||||
* the field impossible to type a negative into. The server's type check then
|
||||
* names the param.
|
||||
*
|
||||
* Re-serialising the whole object rather than splicing text, for `pickParam`'s
|
||||
* reason: a string edit that produced valid-looking JSON with a duplicate key
|
||||
* would be a value the editor and the server read differently.
|
||||
*/
|
||||
export function setParam(step, name, raw, type) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return step?.paramsText || '{}'
|
||||
const next = { ...parsed.params }
|
||||
if (raw === '' || raw === undefined || raw === null) delete next[name]
|
||||
else next[name] = coerceLiteral(type, raw)
|
||||
return JSON.stringify(next, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored `datetime` as a `datetime-local` input wants it, and back.
|
||||
*
|
||||
* The server normalises a datetime param to an ISO string (`conditions.js`
|
||||
* `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
|
||||
* slice is the whole conversion in one direction; in the other the input's own
|
||||
* text is a moment `new Date()` parses, so it is posted as typed and the server
|
||||
* does the normalising — one implementation of what a datetime is, and it is
|
||||
* not this one.
|
||||
*/
|
||||
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
|
||||
|
||||
/**
|
||||
* Everything the meter needs out of the form, and nothing else.
|
||||
*
|
||||
* The price route takes a spec, not a definition: no title, no schedule, no
|
||||
* series. Sending the whole payload would put a document in front of a route
|
||||
* that reads two fields of it — and would fail the moment the rest of the form
|
||||
* is mid-edit, which is exactly when the meter is being read.
|
||||
*
|
||||
* A step whose params do not parse is sent with none rather than dropped, so a
|
||||
* half-typed JSON box costs its own step's draw and not the phase's.
|
||||
*/
|
||||
export function priceBodyFrom(form) {
|
||||
return {
|
||||
phases: (form?.phases || []).map((phase) => ({
|
||||
key: phase.key || null,
|
||||
steps: (phase.steps || []).map((step) => ({
|
||||
actionId: step.actionId || '',
|
||||
params: parseParams(step.paramsText).params || {},
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this plan worth pricing at all?
|
||||
*
|
||||
* A meter that fires on an empty form asks the server what nothing costs, on
|
||||
* every keystroke of the title field. One step with an action chosen is the
|
||||
* threshold, because that is the first moment there is an answer.
|
||||
*/
|
||||
export const worthPricing = (form) =>
|
||||
(form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
|
||||
|
||||
// ── 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',
|
||||
'phase.gate': 'Advance condition set',
|
||||
'condition.evaluated': 'Condition evaluated',
|
||||
'phase.advanced': 'Phase advanced',
|
||||
// Phase 6. "Refused" reads differently from "Step" on purpose: an operator
|
||||
// scanning a stopped run needs to see that nothing is broken.
|
||||
'step.refused': 'Refused',
|
||||
'run.budget': 'Caps',
|
||||
'version.verified': 'Dry run passed',
|
||||
// Phase 15. "Reported" rather than "Detail": the line is the module talking
|
||||
// about its own verb, and every other word here names something core did.
|
||||
'step.detail': 'Step reported',
|
||||
note: 'Note',
|
||||
}
|
||||
|
||||
// How deep and how long a module's own `detail` value is allowed to render.
|
||||
// The dispatcher already caps the whole object at 4KB, so this is about a line
|
||||
// staying a line — an operator scanning a run's log should not have one row
|
||||
// wrap eight times because a module answered with an array of forty names.
|
||||
const DETAIL_LIST_SHOWN = 5
|
||||
const DETAIL_TEXT_MAX = 80
|
||||
|
||||
/**
|
||||
* One value out of a module's `detail`, as text.
|
||||
*
|
||||
* **Core does not interpret these keys and neither does this.** A module wrote
|
||||
* the object; the console shows it. That is the whole reason the renderer is
|
||||
* generic rather than a switch — a switch would be core learning a module's
|
||||
* vocabulary, which is the thing the module system exists to prevent.
|
||||
*/
|
||||
function detailValue(value) {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (Array.isArray(value)) {
|
||||
const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
|
||||
return value.length > DETAIL_LIST_SHOWN
|
||||
? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
|
||||
: shown
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// A nested object is rendered by its keys rather than as JSON: an operator
|
||||
// reading a log wants "granted: 8, missed: 4", not a brace.
|
||||
return Object.entries(value)
|
||||
.map(([k, v]) => `${k} ${detailValue(v)}`)
|
||||
.join(', ')
|
||||
}
|
||||
const text = String(value)
|
||||
return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}…` : text
|
||||
}
|
||||
|
||||
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)' : ''}`
|
||||
case 'phase.gate':
|
||||
return d.kind === 'after'
|
||||
? `${line.phase} advances ${d.after} after it started`
|
||||
: `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}`
|
||||
// Both outcomes are logged, and the near miss is the useful one: it is the
|
||||
// difference between "the boss did spawn, in the wrong region" and "no boss
|
||||
// has spawned", which look identical on every other line of this log.
|
||||
case 'condition.evaluated':
|
||||
return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${
|
||||
d.satisfied ? ', condition met' : ''
|
||||
}`
|
||||
case 'phase.advanced':
|
||||
return d.because === 'forced'
|
||||
? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
|
||||
: `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
|
||||
// Phase 6. `step.refused` is its own kind rather than a `step.status` for a
|
||||
// reason an operator feels at 2am: a refusal is not a failure, and the line
|
||||
// has to say which deployment rule stopped it -- the answer to "not enabled"
|
||||
// is a switch, and the answer to "over the cap" is a number.
|
||||
case 'step.refused':
|
||||
return `${d.action} refused: ${d.error}`
|
||||
case 'run.budget':
|
||||
return (d.dimensions || [])
|
||||
.map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`)
|
||||
.join(', ') || 'no caps apply to this run'
|
||||
case 'version.verified':
|
||||
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
|
||||
// Phase 15. The one line whose body core did not compose: a module may answer
|
||||
// a successful step with a `detail` object, and this renders whatever keys it
|
||||
// put there. `action` is core's own and is pulled out to lead the sentence;
|
||||
// everything after it is the module's.
|
||||
//
|
||||
// **Without this case the row would render as the literal string
|
||||
// "step.detail"**, because the default below is a kind word and not a
|
||||
// sentence — which would be the reporting channel existing and showing
|
||||
// nothing, the exact failure it was built to fix.
|
||||
case 'step.detail': {
|
||||
const { action, ...rest } = d
|
||||
const body = Object.entries(rest)
|
||||
.map(([key, value]) => `${key}: ${detailValue(value)}`)
|
||||
.join(', ')
|
||||
return body ? `${action || 'A step'} — ${body}` : `${action || 'A step'} reported nothing`
|
||||
}
|
||||
default:
|
||||
return logKindWord(line?.kind)
|
||||
}
|
||||
}
|
||||
99
client/src/lib/eventCalendar.js
Normal file
99
client/src/lib/eventCalendar.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Rendering an event's instant, shared by the public event screens.
|
||||
//
|
||||
// **The split these two functions make is EVENTS.md §I's, and it is the one
|
||||
// thing about event times that is easy to get wrong.** The server returns UTC
|
||||
// instants and never guesses the reader's zone. The client places them:
|
||||
//
|
||||
// • the DAY an entry is filed under is the reader's own — "what is on this
|
||||
// month" is a question about the month the person reading is living in;
|
||||
// • the TIME beside it is always the EVENT's zone, carried on the entry —
|
||||
// because every listing this feature replaces is written in the shard's
|
||||
// local zone, and "8pm" means the shard's evening to everyone reading it.
|
||||
//
|
||||
// Rendering the time in the reader's zone instead would be defensible and is
|
||||
// wrong here: a player in Berlin told an American shard's event is at "02:00"
|
||||
// has been told something true and useless, and told it in a way that makes the
|
||||
// shard's own announcement look like a mistake.
|
||||
|
||||
/** The event's own wall clock, with the zone named so it misreads as nothing. */
|
||||
export function eventTime(instant, timezone) {
|
||||
try {
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(new Date(instant))
|
||||
return `${time} ${shortZone(timezone)}`
|
||||
} catch {
|
||||
// An unknown IANA name throws rather than falling back, and an event whose
|
||||
// timezone column holds a typo must still render. UTC off the instant is the
|
||||
// honest answer when the zone cannot be honoured.
|
||||
return `${new Date(instant).toISOString().slice(11, 16)} UTC`
|
||||
}
|
||||
}
|
||||
|
||||
/** The zone as a reader recognises it: `America/New_York` → `New York`. */
|
||||
function shortZone(timezone) {
|
||||
if (!timezone) return 'UTC'
|
||||
const tail = String(timezone).split('/').pop()
|
||||
return tail.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** The reader's own day, for the heading an entry is filed under. */
|
||||
export function readerDayLabel(instant) {
|
||||
const d = new Date(instant)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
/** The event's own day and time together, for a page that shows one occurrence. */
|
||||
export function eventDateTime(instant, timezone) {
|
||||
const d = new Date(instant)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
try {
|
||||
return `${new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(d)} ${shortZone(timezone)}`
|
||||
} catch {
|
||||
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
}
|
||||
}
|
||||
|
||||
// The word beside an entry, for the four public statuses.
|
||||
//
|
||||
// **`cancelled` needs the instant, and that is the whole reason this is a
|
||||
// function rather than a lookup table.** The server publishes `failed` and
|
||||
// `missed` as `cancelled` too — to a visitor those three are one event, and the
|
||||
// difference between them is about the deployment — but the three do not share
|
||||
// one English sentence. "Did not happen" is right for a past occurrence and a
|
||||
// plain falsehood for a future one, and a run four days out that an operator has
|
||||
// called off is exactly the common case: the calendar was saying *did not
|
||||
// happen* about next Friday.
|
||||
//
|
||||
// So the tense follows the clock, not the status. A future call-off reads
|
||||
// **Cancelled**; a past one reads **Did not happen**, which is also the honest
|
||||
// word for the failed and missed runs folded in with it.
|
||||
const WORDS = {
|
||||
live: 'Happening now',
|
||||
scheduled: 'Scheduled',
|
||||
completed: 'Finished',
|
||||
}
|
||||
|
||||
export function statusWord(status, scheduledFor, now = Date.now()) {
|
||||
if (WORDS[status]) return WORDS[status]
|
||||
if (status !== 'cancelled') return status
|
||||
const at = new Date(scheduledFor).getTime()
|
||||
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
|
||||
}
|
||||
@@ -19,3 +19,17 @@ export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/a
|
||||
/** The per-channel preferences screen. */
|
||||
export const notificationSettingsPath = (user) =>
|
||||
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
|
||||
|
||||
/**
|
||||
* This account's own event participation (events Phase 14a).
|
||||
*
|
||||
* The third screen to need this mapping, and it needed it for exactly the reason
|
||||
* the two above did: `GET /player/events/history` is behind `requireAuth` alone,
|
||||
* self-scoped on `req.user.id` — a staff member has a participation history like
|
||||
* anyone else, and the group's own header says staff are a superset of players.
|
||||
* The WEB is what disagrees, because `RequirePlayer` sends them to the login
|
||||
* page. Found the same way the notifications pair was: signed in as an admin,
|
||||
* the screen simply redirected.
|
||||
*/
|
||||
export const eventHistoryPath = (user) =>
|
||||
isStaff(user) ? '/admin/events/mine' : '/account/events'
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
|
||||
// Phase 7): a module may register event actions, budget dimensions, leases and
|
||||
// param option sources. All four are server-side registrations and nothing on
|
||||
// `window.__rg` changed — but what they produce is met on this half, in the step
|
||||
// editor: an option source is what turns a param from a text box into a dropdown
|
||||
// of real values, and a budget's label and unit are what the switchboard's cap
|
||||
// box says beside its number. This file bumps for the reason at the top: the two
|
||||
// halves state ONE version, and a module declares one `coreApi` range against
|
||||
// both.
|
||||
// 1.9.0 - a module may ship its own message bodies and rules:
|
||||
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
|
||||
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
|
||||
// and core's seeders write it on the boot path - but the bodies it ships are
|
||||
// edited through the template editor this half already renders, and an operator
|
||||
// meets them there. This file bumps for the reason at the top: the two halves
|
||||
// state ONE version, and a module declares one `coreApi` range against both.
|
||||
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
|
||||
// this half changed: a ceiling is declared on the server's `api` and enforced
|
||||
// there, and the admin screens that render one read the vocabulary from
|
||||
@@ -58,4 +74,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.8.0'
|
||||
export const MODULE_API_VERSION = '1.10.0'
|
||||
|
||||
@@ -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`
|
||||
@@ -113,6 +114,40 @@ export const NAV = [
|
||||
// "did that message go out", and this answers "why is this person not
|
||||
// getting any" - and it is the only screen that can lift a suppression.
|
||||
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
|
||||
// Last in the group because it is the one screen nobody visits weekly, and
|
||||
// beside Suppressions on purpose: it is where the reader is told that the
|
||||
// fourth engagement table does NOT expire, which is otherwise a silence
|
||||
// that reads as an oversight.
|
||||
{ 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'] },
|
||||
// Phase 4. The same staff gate as the list beside it: a calendar is a read,
|
||||
// and the arcs it manages are authoring gated on the buttons rather than
|
||||
// on the row.
|
||||
{ to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
|
||||
// Phase 6, and the one row in this group that is NOT staff-wide. §K puts
|
||||
// the switchboard in the same row as the world-changing actions it
|
||||
// governs: what a deployment permits at all is configuration, not a read,
|
||||
// and the server gates both the GET and the PUT on `admin`.
|
||||
{ to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] },
|
||||
// Phase 14a, and the one row here that is not about running the
|
||||
// deployment: it is this staff member's OWN attendance, the same screen
|
||||
// and the same route a player reads at /account/events. It has no `roles`
|
||||
// because it needs none — every account has a participation history, and
|
||||
// the server scopes it to the caller.
|
||||
{ to: '/admin/events/mine', label: 'My participation', icon: IconCalendar },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -196,6 +231,12 @@ const TITLES = {
|
||||
'/admin/engagement/triggers': 'Triggers',
|
||||
'/admin/engagement/suppressions': 'Suppressions',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
'/admin/engagement/retention': 'Retention',
|
||||
'/admin/events': 'Events',
|
||||
'/admin/events/calendar': 'Event calendar',
|
||||
'/admin/events/actions': 'Event actions',
|
||||
'/admin/events/mine': 'My participation',
|
||||
'/admin/events/new': 'New event',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
@@ -216,6 +257,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'
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,12 @@ export default function Dashboard() {
|
||||
className="sans"
|
||||
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
|
||||
>
|
||||
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
|
||||
{/* `minWidth` rather than `width`: the column still lines up for core's
|
||||
own short action names, and a longer one — a module's namespaced
|
||||
action, say `rust.account.unlink.staff` — grows the box instead of
|
||||
overflowing it and printing on top of the detail beside it. Found
|
||||
on a live dashboard with a module installed. */}
|
||||
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
|
||||
{a.action}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>
|
||||
|
||||
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Retention (ENGAGEMENT.md Phase 14).
|
||||
//
|
||||
// Three of the four engagement tables grew on every fire and nothing had ever
|
||||
// deleted from any of them. This screen is the policy: how long the deployment
|
||||
// keeps a cooldown row, a finished outbox row and a send-log entry.
|
||||
//
|
||||
// **Why it is a screen, when the other two retention workers in this codebase
|
||||
// (`team_activity`, `user_notifications`) are invisible settings rows.** The
|
||||
// send-log horizon changes what an operator-facing page is *able to show* — the
|
||||
// Send Log is the only answer to "was this person told" — so an operator has to
|
||||
// be able to see it and set it, not discover it by finding rows missing. Having
|
||||
// made one visible, hiding the other two would be the worse split: "what does
|
||||
// this deployment keep" is one question and deserves one answer.
|
||||
//
|
||||
// **The fourth table is on this page as prose, not as a control.** Suppressions
|
||||
// do not expire (org lead, 2026-09-01), and saying so here is the point: an
|
||||
// operator reading a retention screen that lists three tables would reasonably
|
||||
// assume the fourth was an oversight.
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
name: 'sends',
|
||||
label: 'Send log',
|
||||
table: 'engagement_sends',
|
||||
// The one horizon the org lead asked to be pickable rather than typed —
|
||||
// and `custom` stays, because a deployment with a compliance answer to
|
||||
// give should not be limited to three numbers somebody chose.
|
||||
presets: [90, 180, 365],
|
||||
help:
|
||||
'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the '
|
||||
+ 'horizon is also how far back "was this person told" can be answered. The per-rule hourly '
|
||||
+ 'ceiling counts this table too, which is why it can never go below a week.',
|
||||
},
|
||||
{
|
||||
name: 'cooldowns',
|
||||
label: 'Cooldowns',
|
||||
table: 'engagement_cooldowns',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'One row per rule, user, subject and channel, written on every fire. Deleting a row that '
|
||||
+ 'is still in force makes the next fire count as a first fire — that is a duplicate '
|
||||
+ 'message — so this must stay longer than the longest cooldown on any enabled rule.',
|
||||
},
|
||||
{
|
||||
name: 'outbox',
|
||||
label: 'Outbox',
|
||||
table: 'engagement_outbox',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled '
|
||||
+ 'row is a message this deployment still intends to send and is never swept, however old '
|
||||
+ 'the horizon.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function EngagementRetention() {
|
||||
const [policy, setPolicy] = useState(null)
|
||||
const [limits, setLimits] = useState({})
|
||||
const [warnings, setWarnings] = useState([])
|
||||
const [longestCooldown, setLongestCooldown] = useState(0)
|
||||
const [draft, setDraft] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [note, setNote] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const apply = useCallback((result) => {
|
||||
setPolicy(result.retention)
|
||||
setDraft(result.retention)
|
||||
setLimits(result.limits || {})
|
||||
setWarnings(result.warnings || [])
|
||||
setLongestCooldown(result.longestCooldownSeconds || 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const result = await api.admin.getEngagementRetention()
|
||||
if (alive) apply(result)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [apply])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setNote(null)
|
||||
try {
|
||||
// The whole draft, not the changed field: this screen is the one place the
|
||||
// three are set together, and a partial save would leave the warning line
|
||||
// (which is computed from the cooldown horizon) describing a policy that is
|
||||
// half saved. The route itself is sparse, so sending three is legal.
|
||||
const result = await api.admin.setEngagementRetention(draft)
|
||||
apply(result)
|
||||
setNote('Saved.')
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem', maxWidth: 720, marginTop: 0 }}>
|
||||
How long this deployment keeps the engagement system’s own records. A nightly sweep
|
||||
removes anything older, in batches, and skips a table it cannot read rather than failing
|
||||
the run.
|
||||
</p>
|
||||
|
||||
{warnings.map((w) => (
|
||||
<p
|
||||
key={w}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
maxWidth: 720,
|
||||
padding: '10px 12px',
|
||||
borderLeft: '3px solid #d98b84',
|
||||
background: 'rgba(217, 139, 132, 0.08)',
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'grid', gap: 22, maxWidth: 720, marginTop: 20 }}>
|
||||
{FIELDS.map((f) => {
|
||||
const spec = limits[f.name] || {}
|
||||
const value = draft[f.name] ?? ''
|
||||
const isPreset = f.presets.includes(Number(value))
|
||||
return (
|
||||
<div key={f.name}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<span className="field-label" style={{ fontWeight: 600 }}>{f.label}</span>
|
||||
<code className="dim" style={{ fontSize: '0.74rem' }}>{f.table}</code>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '4px 0 8px' }}>
|
||||
{f.help}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label>
|
||||
<span className="field-label">Keep for</span>
|
||||
<select
|
||||
className="select"
|
||||
value={isPreset ? String(value) : 'custom'}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
// Choosing "custom" must not blank the field — the number
|
||||
// box below is what the operator is about to edit, and an
|
||||
// empty one would post NaN.
|
||||
if (next === 'custom') return
|
||||
setDraft({ ...draft, [f.name]: Number(next) })
|
||||
}}
|
||||
>
|
||||
{f.presets.map((d) => (
|
||||
<option key={d} value={String(d)}>{d} days</option>
|
||||
))}
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Days</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={spec.min ?? 2}
|
||||
max={spec.max ?? 3650}
|
||||
style={{ width: 110 }}
|
||||
value={value}
|
||||
onChange={(e) => setDraft({ ...draft, [f.name]: e.target.value === '' ? '' : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
{spec.min !== undefined && (
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem', paddingBottom: 8 }}>
|
||||
{spec.min}–{spec.max} days
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 24 }}>
|
||||
<button type="button" className="pill" disabled={!dirty || saving} onClick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{dirty && (
|
||||
<button type="button" className="pill" disabled={saving} onClick={() => setDraft(policy)}>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
{note && <span className="sans" style={{ fontSize: '0.82rem' }}>{note}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 720, marginTop: 32 }}>
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', marginBottom: 6 }}>
|
||||
Suppressed addresses do not expire
|
||||
</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>
|
||||
A suppression is a standing decision, not a record of something that happened. Ageing one
|
||||
out would re-mail an address that already hard-bounced or asked to be left alone, which is
|
||||
how a sender loses a domain’s reputation. The way out of that list stays a
|
||||
deliberate act:{' '}
|
||||
<strong>Lift</strong> on the row, in Admin → Engagement → Suppressions.
|
||||
</p>
|
||||
{longestCooldown > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', marginBottom: 0 }}>
|
||||
The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,11 @@ export default function EngagementSendLog() {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
// Phase 14. `total` is now a truncated number, and a screen that shows a total
|
||||
// without saying so is quietly wrong about the deployment's own history — this
|
||||
// is the fix for that, and the reason the horizon got an operator-facing
|
||||
// control rather than the invisible settings row the other two sweeps use.
|
||||
const [retainDays, setRetainDays] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
@@ -55,6 +60,15 @@ export default function EngagementSendLog() {
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
// Best-effort and non-blocking: the log is worth showing even if the policy
|
||||
// cannot be read, so a failure here leaves the note off rather than the
|
||||
// screen empty.
|
||||
try {
|
||||
const policy = await api.admin.getEngagementRetention()
|
||||
setRetainDays(policy?.retention?.sends ?? null)
|
||||
} catch {
|
||||
setRetainDays(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,6 +167,7 @@ export default function EngagementSendLog() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
|
||||
@@ -126,6 +126,26 @@ export default function EngagementSuppressions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row Lift (Phase 14). No address is asked for and none is needed: the
|
||||
* row carries its own `address_hash`, which is the only handle this screen has
|
||||
* ever been able to have — the address itself is stored one-way.
|
||||
*
|
||||
* No confirm dialog, deliberately. Lifting is reversible in one click (the
|
||||
* Suppress field above is right there), and a browser modal blocks the whole
|
||||
* tab, which is the failure mode the automation notes in this repo warn about.
|
||||
*/
|
||||
async function liftRow(row) {
|
||||
setNote(null)
|
||||
try {
|
||||
await api.admin.unsuppressByHash(row.address_hash, row.channel)
|
||||
setNote(`${row.address_masked || 'That address'} can be mailed again.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
@@ -211,6 +231,7 @@ export default function EngagementSuppressions() {
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Since</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -231,6 +252,20 @@ export default function EngagementSuppressions() {
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
disabled={!r.address_hash}
|
||||
title={r.address_hash
|
||||
? 'Let this address be mailed again'
|
||||
: 'This row has no handle to act on'}
|
||||
onClick={() => liftRow(r)}
|
||||
>
|
||||
Lift
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
280
client/src/routes/admin/views/EventActions.jsx
Normal file
280
client/src/routes/admin/views/EventActions.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6).
|
||||
//
|
||||
// **This screen is the whole of the permission model beyond the role.** A module
|
||||
// declaring `uo.creature.spawn` is code the operator installed; it is not a
|
||||
// permission they granted. Enablement is the grant, and the cap is how much of
|
||||
// it — so this is the one screen in the feature where an operator decides what
|
||||
// the deployment *can do at all*, rather than what it is going to do tonight.
|
||||
//
|
||||
// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module
|
||||
// must never start doing things, which is the posture a seeded engagement rule
|
||||
// already takes by arriving `enabled = 0`. The line falls between `inspect` and
|
||||
// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
// nothing, so a deployment gains no risk by having it on, and `core.wait` — which
|
||||
// is `inspect` — arriving off would break every published event that waits.
|
||||
//
|
||||
// **A row with no stored setting is not "off".** It is "the default for its risk
|
||||
// class", computed on the server by the same function the runner asks. The screen
|
||||
// says which it is looking at, because "an admin turned this on" and "this has
|
||||
// always been on" are different facts and only one of them is a decision.
|
||||
//
|
||||
// **Admin only in both directions**, including the read: §K puts the switchboard
|
||||
// in the same row as the world-changing actions it governs, and knowing exactly
|
||||
// what a deployment permits is not a staff-wide read.
|
||||
|
||||
const RISK_WORD = {
|
||||
notify: 'Tells people something',
|
||||
inspect: 'Reads the world',
|
||||
change: 'Changes the world',
|
||||
irreversible: 'Changes the world irreversibly',
|
||||
}
|
||||
|
||||
const RISK_COLOR = {
|
||||
notify: 'var(--muted)',
|
||||
inspect: 'var(--muted)',
|
||||
change: '#d9c184',
|
||||
irreversible: '#d98b84',
|
||||
}
|
||||
|
||||
const REVERSIBLE_WORD = {
|
||||
none: 'nothing to undo',
|
||||
self: 'undoes itself',
|
||||
ledger: 'undone from the ledger at teardown',
|
||||
override: 'restores a baseline',
|
||||
}
|
||||
|
||||
export default function EventActions() {
|
||||
const [actions, setActions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
// Cap edits are held here until they are saved, keyed `actionId:dimension`.
|
||||
// A cap is a number somebody types digit by digit, and writing on every
|
||||
// keystroke would put "3" in the database on the way to "30".
|
||||
const [drafts, setDrafts] = useState({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.admin.eventActions()
|
||||
setActions(data.actions || [])
|
||||
}, [])
|
||||
|
||||
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])
|
||||
|
||||
/**
|
||||
* Write one action's row.
|
||||
*
|
||||
* The whole row goes every time — the switch and every cap — because the route
|
||||
* takes one action per request and a sparse write would have to decide what an
|
||||
* omitted cap means. Here it can only mean one thing, so it is sent.
|
||||
*/
|
||||
const save = async (action, { enabled = action.enabled, caps } = {}) => {
|
||||
setBusy(action.id)
|
||||
setProblem(null)
|
||||
setNotice(null)
|
||||
const nextCaps = caps !== undefined ? caps : capsOf(action)
|
||||
try {
|
||||
await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps })
|
||||
await load()
|
||||
setDrafts((d) => {
|
||||
const next = { ...d }
|
||||
for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
|
||||
return next
|
||||
})
|
||||
setNotice(`Saved ${action.label}.`)
|
||||
} catch (err) {
|
||||
setProblem(err.message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** The caps this row would save: the drafts on top of what is stored. */
|
||||
const capsOf = (action) => {
|
||||
const out = {}
|
||||
for (const { id: dimension } of action.dimensions) {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
const value = draft !== undefined ? draft : action.caps[dimension]
|
||||
if (value === '' || value === undefined || value === null) continue
|
||||
out[dimension] = Number(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const capValue = (action, dimension) => {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
if (draft !== undefined) return draft
|
||||
const stored = action.caps[dimension]
|
||||
return stored === undefined || stored === null ? '' : String(stored)
|
||||
}
|
||||
|
||||
const dirty = (action) =>
|
||||
action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px' }}>Event actions</h2>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: '62ch' }}>
|
||||
What this deployment permits an event to do, and how much of it per run. Anything that changes
|
||||
the world arrives switched off — installing a module declares a verb, it does not grant
|
||||
permission to use it. Caps are copied into a run when the run is created, so moving a switch
|
||||
never changes what a run already in flight is allowed.
|
||||
</p>
|
||||
|
||||
{problem && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #d98b84' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{problem}</span>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #8fc79a' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.length === 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
No module registers an event action. Core always declares its own three, so an empty list
|
||||
here means the registry did not load.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="panel-flat"
|
||||
style={{
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
borderLeft: `3px solid ${action.enabled ? RISK_COLOR[action.risk] || 'var(--rule)' : 'var(--rule)'}`,
|
||||
opacity: action.enabled ? 1 : 0.75,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<strong className="sans" style={{ fontSize: '0.95rem' }}>{action.label}</strong>
|
||||
<code className="dim" style={{ fontSize: '0.78rem' }}>{action.id}</code>
|
||||
</div>
|
||||
{action.description && (
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.82rem' }}>{action.description}</p>
|
||||
)}
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.78rem' }}>
|
||||
<span style={{ color: RISK_COLOR[action.risk] }}>{RISK_WORD[action.risk] || action.risk}</span>
|
||||
{' · '}
|
||||
{REVERSIBLE_WORD[action.reversible] || action.reversible}
|
||||
{/* Which of the two facts this is. A default is not a decision, and
|
||||
an operator auditing their own deployment needs to see the
|
||||
difference without reading the risk table in their head. */}
|
||||
{' · '}
|
||||
{action.configured
|
||||
? `set by ${action.updatedBy || 'an administrator'}`
|
||||
: 'never configured — showing the default for its risk class'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="sans" style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={action.enabled}
|
||||
disabled={busy === action.id}
|
||||
onChange={(e) => save(action, { enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{action.dimensions.length > 0 && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--rule)' }}>
|
||||
<p className="sans dim" style={{ margin: '0 0 6px', fontSize: '0.78rem' }}>
|
||||
Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds
|
||||
it. Where another enabled action spends the same thing, the tightest cap is the one a
|
||||
run gets.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
{action.dimensions.map((d) => (
|
||||
<label key={d.id} className="sans" style={{ fontSize: '0.8rem' }}>
|
||||
{/*
|
||||
The LABEL, with the unit beside the box — both from the module's
|
||||
`registerEventBudgets` declaration (Phase 7). Before it, this said
|
||||
`uo.creatures` over an unlabelled number, which is ambiguous in exactly
|
||||
the case that matters: 30 of what?
|
||||
*/}
|
||||
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>
|
||||
{d.registered ? d.label : d.id}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
style={{ width: 110 }}
|
||||
value={capValue(action, d.id)}
|
||||
disabled={busy === action.id || !d.registered}
|
||||
onChange={(e) =>
|
||||
setDrafts((s) => ({ ...s, [`${action.id}:${d.id}`]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{d.registered && d.unit && (
|
||||
<span className="dim" style={{ fontSize: '0.75rem' }}>{d.unit}</span>
|
||||
)}
|
||||
</span>
|
||||
{/*
|
||||
A dimension nobody declares is SHOWN rather than hidden. The action is
|
||||
refused when it is saved into a step and again if it is ever dispatched,
|
||||
so the operator needs to be told which module is incomplete — hiding the
|
||||
row would make a broken module look like a cheap one.
|
||||
*/}
|
||||
{!d.registered && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ display: 'block', marginTop: 2, fontSize: '0.72rem', color: '#d98b84' }}
|
||||
>
|
||||
No module declares this as a budget, so a step using this action is
|
||||
refused. It cannot be capped until one does.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy === action.id || !dirty(action)}
|
||||
onClick={() => save(action)}
|
||||
>
|
||||
Save caps
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1553
client/src/routes/admin/views/EventEditor.jsx
Normal file
1553
client/src/routes/admin/views/EventEditor.jsx
Normal file
File diff suppressed because it is too large
Load Diff
722
client/src/routes/admin/views/EventRun.jsx
Normal file
722
client/src/routes/admin/views/EventRun.jsx
Normal file
@@ -0,0 +1,722 @@
|
||||
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.
|
||||
//
|
||||
// **Phase 5 gave it a second one of those, and the panel is this phase's real
|
||||
// deliverable** (§ Observability): a phase whose steps have all finished and
|
||||
// whose advance condition has not been met is also `running` and also
|
||||
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
|
||||
// steps, in the condition builder's own words — and the sentence is the
|
||||
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
|
||||
// defined in the condition grammar and a second renderer in the browser would
|
||||
// be a second opinion about what `gte` reads as.
|
||||
//
|
||||
// **Phase 8 gave it a third, and it is the one that outlives the event.** The
|
||||
// resource ledger is what this run changed in the world and what became of it,
|
||||
// and its unresolved rows are the reason a `completed` run can still need a
|
||||
// person — EVENTS.md §L: a run reaches `completed` with `cleanup_status =
|
||||
// 'incomplete'` rather than being held open, because a tidy `completed` row over
|
||||
// a shard full of orphaned monsters is the failure that would end this feature's
|
||||
// credibility on its first bad night. The panel is shown on finished runs for
|
||||
// exactly that reason, and it is the only panel here whose empty state matters.
|
||||
|
||||
const POLL_MS = 5000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
completed: '#8fc79a',
|
||||
}
|
||||
|
||||
// The six ledger statuses, in the two groups that matter to a reader: green is
|
||||
// resolved, amber wants a person. `orphaned` and `drifted` are amber rather than
|
||||
// red because neither is a fault — one thing vanished, the other was taken by
|
||||
// somebody with every right to take it — and red is reserved for "this did not
|
||||
// come back and core kept asking".
|
||||
const RESOURCE_COLOR = {
|
||||
reverted: '#8fc79a',
|
||||
confirmed: '#d9c184',
|
||||
pending: '#d9c184',
|
||||
reverting: '#d9c184',
|
||||
drifted: '#d9c184',
|
||||
orphaned: '#d9c184',
|
||||
}
|
||||
|
||||
const RESOURCE_WORD = {
|
||||
pending: 'recorded, unconfirmed',
|
||||
confirmed: 'still out there',
|
||||
reverting: 'being given back',
|
||||
reverted: 'given back',
|
||||
orphaned: 'gone',
|
||||
drifted: 'someone else moved it',
|
||||
}
|
||||
|
||||
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() : '')
|
||||
|
||||
// How many participants the console renders before it stops and counts the rest.
|
||||
// A run's participants are people and a busy event has hundreds; this panel is a
|
||||
// check that the collection worked and that the ranking looks right, not the
|
||||
// results page — that is Phase 14's, and it is public.
|
||||
const PARTICIPANTS_SHOWN = 50
|
||||
|
||||
/**
|
||||
* Seconds as an operator reads them — the same vocabulary the spec authors a
|
||||
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
|
||||
* obviously the same kind of thing.
|
||||
*/
|
||||
function elapsed(seconds) {
|
||||
const s = Math.max(0, Number(seconds) || 0)
|
||||
if (s < 60) return `${s} sec`
|
||||
if (s < 3600) return `${Math.floor(s / 60)} min`
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
return m ? `${h} hr ${m} min` : `${h} hr`
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase gate, as the panel draws it.
|
||||
*
|
||||
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
|
||||
* was released by the third boss" is the same question as the live one, asked
|
||||
* after the fact, and it is the one an operator asks the morning after.
|
||||
*/
|
||||
function GateRow({ gate, current }) {
|
||||
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
|
||||
return (
|
||||
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
|
||||
Phase <strong>{gate.phase}</strong>
|
||||
{current && !gate.satisfied ? ' has not started' : ''}
|
||||
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
|
||||
{gate.stalled && ' — STALLED'}
|
||||
</div>
|
||||
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
|
||||
{gate.kind === 'after' ? (
|
||||
<>
|
||||
<dt className="dim">waiting for</dt>
|
||||
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
|
||||
<dt className="dim">until</dt>
|
||||
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<dt className="dim">waiting on</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
<code>{gate.waitingOn}</code>
|
||||
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> — any firing</span>}
|
||||
</dd>
|
||||
<dt className="dim">seen so far</dt>
|
||||
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="dim">since</dt>
|
||||
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
|
||||
{gate.kind === 'on' && gate.lastEvent && (
|
||||
<>
|
||||
<dt className="dim">last related event</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
|
||||
{' — '}
|
||||
{/* The near miss is the valuable half: "the boss did spawn, in
|
||||
Britain" and "no boss has spawned" are different answers and
|
||||
look identical without this line. */}
|
||||
{gate.lastEvent.matched ? 'counted' : 'did not count'}
|
||||
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
|
||||
<span className="dim">
|
||||
{' ('}
|
||||
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
|
||||
{')'}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EventRun() {
|
||||
const { runId } = useParams()
|
||||
const [run, setRun] = useState(null)
|
||||
const [steps, setSteps] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [gates, setGates] = useState([])
|
||||
// The caps this run was given and what it has spent of them (Phase 6). Copied
|
||||
// into the run when it was created, so this is what THIS run is allowed rather
|
||||
// than what the switchboard says today.
|
||||
const [budget, setBudget] = useState([])
|
||||
// What this run created or borrowed, and what became of each (Phase 8).
|
||||
const [resources, setResources] = useState([])
|
||||
const [unresolved, setUnresolved] = useState(0)
|
||||
// Who took part, best first (Phase 10). Present whether or not the results
|
||||
// have been published; `run.resultsPublishedAt` is what says which.
|
||||
const [participants, setParticipants] = 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 || {})
|
||||
setGates(detail.gates || [])
|
||||
setBudget(detail.budget || [])
|
||||
setResources(detail.resources || [])
|
||||
setUnresolved(detail.unresolvedResources || 0)
|
||||
setParticipants(detail.participants || [])
|
||||
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, gates, steps)
|
||||
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
|
||||
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.advance}
|
||||
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
|
||||
Advance phase
|
||||
</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>
|
||||
{/* The separate, admin-only decision (§L). It is a second button rather
|
||||
than a checkbox on the first because the two are not variants of one
|
||||
action: one gives the world back, the other deliberately leaves it
|
||||
changed. A checkbox next to Cancel is a thing an operator unticks by
|
||||
accident at two in the morning. The server refuses this to a
|
||||
moderator, and the refusal arrives as a sentence in `problem`. */}
|
||||
{controls.cancel && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason, false))}>
|
||||
Cancel, leave changes up
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ── Why this phase has not started (Phase 5) ──
|
||||
Above the step list for the same reason the parked cue is: a phase
|
||||
waiting on a condition is `running` and looks completely healthy, and
|
||||
the one screen an operator opens to find out why nothing is happening
|
||||
must say so before they have to read a log. */}
|
||||
{gates.length > 0 && (
|
||||
<div
|
||||
className="panel-flat"
|
||||
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
|
||||
>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
|
||||
{waiting ? (
|
||||
<>
|
||||
Every step of this phase has finished. It advances when the condition below is met —
|
||||
nothing times out, and <em>Advance phase</em> is how a person overrides it.
|
||||
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
|
||||
</>
|
||||
) : (
|
||||
'What each phase of this run waited for, and what released it.'
|
||||
)}
|
||||
</p>
|
||||
{gates.map((gate) => (
|
||||
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What this run is allowed, and what it has spent ──
|
||||
A meter rather than a sentence: a cap is two numbers and a name, and
|
||||
unlike a gate it needs no grammar rendered to be read. It is shown for
|
||||
every run that has a budget at all, finished ones included — "how much
|
||||
did last night's invasion actually spawn" is the same question asked
|
||||
the morning after. */}
|
||||
{budget.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Caps</h3>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{budget.map((b) => {
|
||||
const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1)
|
||||
const full = b.cap !== null && b.consumed >= b.cap
|
||||
return (
|
||||
<tr key={b.dimension}>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{b.dimension}</code>
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: full ? '#d9c184' : undefined }}>
|
||||
{b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`}
|
||||
</td>
|
||||
<td style={{ width: '100%', padding: '3px 0' }}>
|
||||
{b.cap === null ? (
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>no cap</span>
|
||||
) : (
|
||||
<span style={{ display: 'block', height: 6, background: 'var(--rule)', borderRadius: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
height: 6,
|
||||
width: `${Math.round(spent * 100)}%`,
|
||||
background: full ? '#d9c184' : '#8fc79a',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Which switch set the number, so an operator can trace a cap
|
||||
back to a thing they can change rather than wondering
|
||||
where 30 came from. */}
|
||||
<td className="dim" style={{ padding: '3px 0 3px 12px', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{b.from || ''}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What this run changed in the world (Phase 8) ──
|
||||
The WHOLE ledger, reverted rows included: "how much did last night's
|
||||
invasion actually spawn, and did all of it come back" is one question
|
||||
with two halves, and a list of only the failures answers neither.
|
||||
Shown on finished runs for the same reason the caps meter is. */}
|
||||
{(resources.length > 0 || run.cleanupStatus === 'incomplete') && (
|
||||
<div
|
||||
className="panel-flat"
|
||||
style={{
|
||||
padding: 14,
|
||||
marginBottom: 14,
|
||||
borderLeft: `3px solid ${unresolved > 0 ? '#d9c184' : 'var(--rule)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
What this run changed
|
||||
</h3>
|
||||
{/* The manual retry. Offered only on a terminal run, because a run
|
||||
still in flight has a ledger that is still growing and reverting a
|
||||
resource the next step is about to use would be undoing an event
|
||||
while it is happening. */}
|
||||
{isTerminalRun(run.status) && unresolved > 0 && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.cleanupEventRun(run.id))}>
|
||||
Try cleanup again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
{unresolved > 0 ? (
|
||||
<>
|
||||
{unresolved} of these {unresolved === 1 ? 'is' : 'are'} still unresolved. The runner
|
||||
gives them back on its own and stops asking after a few tries;{' '}
|
||||
<em>Try cleanup again</em> clears that count and asks once more.
|
||||
</>
|
||||
) : (
|
||||
'Everything this run created or borrowed has been given back.'
|
||||
)}
|
||||
</p>
|
||||
{resources.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
Nothing named — a step changed the world and its answer never arrived, so core kept the
|
||||
record it wrote beforehand and will ask the module to undo it by key.
|
||||
</p>
|
||||
) : (
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{resources.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{r.kind}</code>{' '}
|
||||
<code className="dim" style={{ fontSize: '0.78rem' }}>{r.ref}</code>
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: RESOURCE_COLOR[r.status] }}>
|
||||
{RESOURCE_WORD[r.status] || r.status}
|
||||
</td>
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{r.module}
|
||||
{r.leaseUntil ? ` · until ${clock(r.leaseUntil)}` : ''}
|
||||
{r.revertAttempts > 0 ? ` · ${r.revertAttempts} attempt${r.revertAttempts === 1 ? '' : 's'}` : ''}
|
||||
</td>
|
||||
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
||||
{r.lastError || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Who took part (Phase 10) ──
|
||||
Shown whenever a module has reported anybody, published or not — and the
|
||||
difference between the two is the whole point of the line under the
|
||||
heading. A run whose participants are collected and unranked is a real
|
||||
state, not an error: an author has not placed a `core.results.publish`
|
||||
step, or has not run it yet. Saying "not published yet" is what stops
|
||||
somebody reading this table as the final standings. */}
|
||||
{participants.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
Who took part
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
{run.resultsPublishedAt ? (
|
||||
<>Results published {clock(run.resultsPublishedAt)}. Ranked best first.</>
|
||||
) : (
|
||||
<>
|
||||
{participants.length} recorded, and the results have not been published — nothing
|
||||
outside this page shows them, and nobody has a rank yet. Publishing is a{' '}
|
||||
<code style={{ fontSize: '0.78rem' }}>core.results.publish</code> step in the event
|
||||
itself.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => (
|
||||
<tr key={p.memberKey}>
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', width: 34, textAlign: 'right' }}>
|
||||
{p.rank ?? ''}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{p.memberKey}</code>
|
||||
</td>
|
||||
{/* A participant with no `userId` is not a defect: it is
|
||||
somebody who turned up without a linked website account,
|
||||
and the module is the only thing that could have known
|
||||
otherwise. Saying so beats a blank cell. */}
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{p.userId ? `account ${p.userId}` : 'no linked account'}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>{p.score}</td>
|
||||
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
||||
{clock(p.joinedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{participants.length > PARTICIPANTS_SHOWN && (
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.78rem' }}>
|
||||
and {participants.length - PARTICIPANTS_SHOWN} more.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 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>
|
||||
)
|
||||
}
|
||||
265
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
265
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
@@ -0,0 +1,265 @@
|
||||
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.
|
||||
//
|
||||
// **The calendar is a separate screen, not a third table here.** It answers
|
||||
// "when", this one answers "what" — and Phase 4, which built it, also made a
|
||||
// definition able to carry a recurrence, so the two questions stopped having the
|
||||
// same answer the moment an occurrence could exist before anybody pressed Start.
|
||||
|
||||
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. A definition can repeat — once, weekly, or on the nth weekday of the month, in its
|
||||
own timezone — and the <Link to="/admin/events/calendar">calendar</Link> is where those
|
||||
occurrences are read.
|
||||
</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>
|
||||
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/calendar">
|
||||
Calendar
|
||||
</Link>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
457
client/src/routes/admin/views/EventsCalendar.jsx
Normal file
457
client/src/routes/admin/views/EventsCalendar.jsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } 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, isProjected } from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → Calendar (EVENTS.md §I, Phase 4).
|
||||
//
|
||||
// **This screen is the deliverable.** What this feature replaces is a WordPress
|
||||
// calendar plugin with no series field, no recurrence and no results — so a
|
||||
// month grid that knows about arcs, repeats and local time is not decoration
|
||||
// here, it is the point.
|
||||
//
|
||||
// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*:
|
||||
// a real row with a status, a pinned version and a console, and somebody can
|
||||
// cancel it. A dashed one is a *projection*: arithmetic past the runner's
|
||||
// fourteen-day horizon, with no row behind it, nothing committed and nothing to
|
||||
// open. An operator who treats a forecast as a booking has been misled by the
|
||||
// UI, not by the server, so the difference is drawn rather than merely stated —
|
||||
// and the legend says which is which.
|
||||
//
|
||||
// **The grid's date axis is the READER's zone; each entry's time is the
|
||||
// EVENT's.** §E gives the timezone to the event because every listing this
|
||||
// replaces is written in the shard's local zone, but "what is happening this
|
||||
// month" is a question about the month the person reading is living in. So the
|
||||
// cell an event lands in is the reader's date, and the time beside it always
|
||||
// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing.
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
}
|
||||
|
||||
/** The event's own wall clock, which is the only time worth showing beside it. */
|
||||
function localTime(instant, timezone) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(new Date(instant))
|
||||
} catch {
|
||||
return new Date(instant).toISOString().slice(11, 16)
|
||||
}
|
||||
}
|
||||
|
||||
/** The reader's own date key, which is what places an entry in a cell. */
|
||||
const readerDayKey = (instant) => {
|
||||
const d = new Date(instant)
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The six-week grid a month view draws, Monday first.
|
||||
*
|
||||
* Always six weeks rather than however many the month needs: a grid that
|
||||
* changes height as you page through it is a grid whose rows move under the
|
||||
* cursor.
|
||||
*/
|
||||
function monthGrid(year, month) {
|
||||
const first = new Date(year, month, 1)
|
||||
const offset = (first.getDay() + 6) % 7
|
||||
const start = new Date(year, month, 1 - offset)
|
||||
return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS))
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
export default function EventsCalendar() {
|
||||
const { user } = useAuth()
|
||||
const today = useMemo(() => new Date(), [])
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
const [view, setView] = useState('month')
|
||||
const [seriesId, setSeriesId] = useState('')
|
||||
const [series, setSeries] = useState([])
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [managingSeries, setManagingSeries] = useState(false)
|
||||
|
||||
const mayAuthor = user?.role === 'admin' || user?.role === 'editor'
|
||||
|
||||
// The window is the grid's own span, not the month's: an entry in the leading
|
||||
// or trailing week of the grid belongs to a neighbouring month and still has
|
||||
// to be fetched, or the first row of every month renders empty.
|
||||
const grid = useMemo(() => monthGrid(year, month), [year, month])
|
||||
const window = useMemo(() => {
|
||||
if (view === 'month') {
|
||||
return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) }
|
||||
}
|
||||
// The list view answers a different question — "what is coming" — so it runs
|
||||
// forward from now rather than over a calendar month.
|
||||
const from = new Date()
|
||||
return { from, to: new Date(from.getTime() + 60 * DAY_MS) }
|
||||
}, [view, grid])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [calendar, seriesList] = await Promise.all([
|
||||
api.admin.eventCalendar({
|
||||
from: window.from.toISOString(),
|
||||
to: window.to.toISOString(),
|
||||
seriesId: seriesId || undefined,
|
||||
}),
|
||||
api.admin.eventSeries(),
|
||||
])
|
||||
setData(calendar)
|
||||
setSeries(seriesList.series || [])
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [window.from, window.to, seriesId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const entry of data?.entries || []) {
|
||||
const key = readerDayKey(entry.scheduledFor)
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key).push(entry)
|
||||
}
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
const step = (delta) => {
|
||||
const next = new Date(year, month + delta, 1)
|
||||
setYear(next.getFullYear())
|
||||
setMonth(next.getMonth())
|
||||
}
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} onRetry={load} />
|
||||
|
||||
const horizon = data?.horizon ? new Date(data.horizon) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{/*
|
||||
The stepper belongs to the MONTH view only. The list answers "what is
|
||||
coming" and runs sixty days forward from now whatever month is
|
||||
selected -- so paging it would be three controls that visibly do
|
||||
nothing, which is the one thing this feature has refused since Phase
|
||||
1. The heading says which question is being asked instead.
|
||||
*/}
|
||||
{view === 'month' && (
|
||||
<>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(-1)}>
|
||||
←
|
||||
</button>
|
||||
<strong className="sans" style={{ fontSize: '0.95rem', minWidth: 150, textAlign: 'center' }}>
|
||||
{`${MONTH_NAMES[month]} ${year}`}
|
||||
</strong>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(1)}>
|
||||
→
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
disabled={year === today.getFullYear() && month === today.getMonth()}
|
||||
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}>
|
||||
Today
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'list' && (
|
||||
<strong className="sans" style={{ fontSize: '0.95rem' }}>The next 60 days</strong>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
|
||||
<select className="select" value={seriesId} onChange={(e) => setSeriesId(e.target.value)}
|
||||
style={{ minWidth: 170 }}>
|
||||
<option value="">Every series</option>
|
||||
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<button type="button" className="pill" aria-pressed={view === 'month'}
|
||||
style={{ fontSize: '0.72rem', opacity: view === 'month' ? 1 : 0.5 }}
|
||||
onClick={() => setView('month')}>
|
||||
Month
|
||||
</button>
|
||||
<button type="button" className="pill" aria-pressed={view === 'list'}
|
||||
style={{ fontSize: '0.72rem', opacity: view === 'list' ? 1 : 0.5 }}
|
||||
onClick={() => setView('list')}>
|
||||
List
|
||||
</button>
|
||||
{mayAuthor && (
|
||||
<button type="button" className="pill" aria-pressed={managingSeries}
|
||||
style={{ fontSize: '0.72rem', opacity: managingSeries ? 1 : 0.6 }}
|
||||
onClick={() => setManagingSeries((v) => !v)}>
|
||||
Series
|
||||
</button>
|
||||
)}
|
||||
<Link to="/admin/events" className="pill" style={{ fontSize: '0.72rem' }}>Events</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The legend is not optional. The whole screen rests on the reader
|
||||
knowing that a dashed entry is not a booking. */}
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
<span style={{ ...chip, borderStyle: 'solid' }}>Scheduled run</span> is a real occurrence with
|
||||
a console — it can be opened, paused and cancelled.{' '}
|
||||
<span style={{ ...chip, borderStyle: 'dashed', opacity: 0.7 }}>Forecast</span> is what the
|
||||
recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is
|
||||
committed yet and there is nothing to open.
|
||||
{horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}
|
||||
</p>
|
||||
|
||||
{managingSeries && <SeriesManager series={series} onChanged={load} />}
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d9c184' }}>
|
||||
This window has more than the calendar will draw. Narrow it by series, or page to a
|
||||
shorter span.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view === 'month' ? (
|
||||
<div className="panel-flat" style={{ padding: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 4 }}>
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
|
||||
<div key={d} className="sans dim" style={{ fontSize: '0.72rem', textAlign: 'center', padding: '2px 0' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{grid.map((day) => {
|
||||
const entries = byDay.get(readerDayKey(day)) || []
|
||||
const outside = day.getMonth() !== month
|
||||
const isToday = readerDayKey(day) === readerDayKey(today)
|
||||
return (
|
||||
<div key={day.toISOString()}
|
||||
style={{
|
||||
minHeight: 84,
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
border: isToday ? '1px solid var(--accent, #8fc79a)' : '1px solid transparent',
|
||||
background: outside ? 'transparent' : 'rgba(255,255,255,0.03)',
|
||||
opacity: outside ? 0.4 : 1,
|
||||
}}>
|
||||
<div className="sans dim" style={{ fontSize: '0.7rem', marginBottom: 3 }}>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
{entries.map((entry) => <EntryChip key={entryKey(entry)} entry={entry} />)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel-flat" style={{ padding: 4 }}>
|
||||
{(data?.entries || []).length === 0 ? (
|
||||
<p className="sans dim" style={{ padding: 14, margin: 0, fontSize: '0.84rem' }}>
|
||||
Nothing is scheduled in the next sixty days.{' '}
|
||||
{mayAuthor && <Link to="/admin/events/new">Author an event</Link>}
|
||||
</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Event</th>
|
||||
<th>Series</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.entries || []).map((entry) => (
|
||||
<tr key={entryKey(entry)} style={{ opacity: isProjected(entry) ? 0.7 : 1 }}>
|
||||
<td className="sans" style={{ fontSize: '0.82rem', whiteSpace: 'nowrap' }}>
|
||||
{new Date(entry.scheduledFor).toLocaleDateString()}{' '}
|
||||
<span className="dim">
|
||||
{localTime(entry.scheduledFor, entry.timezone)} {entry.timezone}
|
||||
</span>
|
||||
</td>
|
||||
<td className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
{entry.runId ? (
|
||||
<Link to={`/admin/events/runs/${entry.runId}`}>{entry.title}</Link>
|
||||
) : (
|
||||
<Link to={`/admin/events/${entry.definitionId}`}>{entry.title}</Link>
|
||||
)}
|
||||
{entry.adjusted === 'gap' && (
|
||||
<span className="dim" title="Daylight saving skips the time this was authored at, so it moves forward to the next one that exists">
|
||||
{' '}(clocks change)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="sans dim" style={{ fontSize: '0.8rem' }}>{entry.seriesName || '—'}</td>
|
||||
<td className="sans" style={{ fontSize: '0.8rem', color: STATUS_COLOR[entry.status] }}>
|
||||
{isProjected(entry) ? <span className="dim">Forecast</span> : runStatusWord(entry.status)}
|
||||
{entry.waitingSteps > 0 && (
|
||||
<span style={{ color: '#d9c184' }}> · waiting on a person</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// A projection has no run id, so the definition and the instant are its
|
||||
// identity — the same pair the server dedupes projections against.
|
||||
const entryKey = (entry) =>
|
||||
entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}`
|
||||
|
||||
const chip = {
|
||||
display: 'inline-block',
|
||||
padding: '0 5px',
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
border: '1px solid var(--muted)',
|
||||
fontSize: '0.72rem',
|
||||
}
|
||||
|
||||
/**
|
||||
* The arcs, managed where they are used.
|
||||
*
|
||||
* A series is a label, not authored content — nothing pins one and no run
|
||||
* references one — so this is a small inline panel rather than a screen of its
|
||||
* own, and it lives on the calendar because the calendar is what makes an arc
|
||||
* visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message
|
||||
* From the Void" is continuity that exists nowhere in the tooling this replaces.*
|
||||
*
|
||||
* The delete is a real delete, and it says what it will detach before it
|
||||
* happens: `series_id` is ON DELETE SET NULL, so the definitions survive without
|
||||
* an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed,
|
||||
* which is why this is the one delete in this feature that is not an archive.
|
||||
*/
|
||||
function SeriesManager({ series, onChanged }) {
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState(null)
|
||||
|
||||
const run = async (fn) => {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 12 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Series</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
|
||||
An arc several events form together. The order here is where a series sits among the
|
||||
others; where an event sits <em>within</em> its arc is that event’s own order, in the
|
||||
editor.
|
||||
</p>
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{problem}</p>
|
||||
)}
|
||||
|
||||
{series.map((s) => (
|
||||
<div key={s.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input className="input" defaultValue={s.name} disabled={busy} style={{ flex: 1 }}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim()
|
||||
if (next && next !== s.name) {
|
||||
run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering }))
|
||||
}
|
||||
}} />
|
||||
<input className="input" type="number" defaultValue={s.ordering} disabled={busy}
|
||||
style={{ width: 72 }} aria-label={`Order of ${s.name}`}
|
||||
onBlur={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
if (Number.isInteger(next) && next !== s.ordering) {
|
||||
run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next }))
|
||||
}
|
||||
}} />
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', minWidth: 70 }}>
|
||||
{s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<button type="button" className="pill" disabled={busy} style={{ fontSize: '0.7rem' }}
|
||||
onClick={() => {
|
||||
// The count is in the question, because the consequence of this
|
||||
// delete is entirely about the rows it does not delete.
|
||||
const ask = s.definitionCount
|
||||
? `Delete "${s.name}"? ${s.definitionCount} event(s) will keep their content and lose this series.`
|
||||
: `Delete "${s.name}"?`
|
||||
// eslint-disable-next-line no-alert
|
||||
if (window.confirm(ask)) run(() => api.admin.deleteEventSeries(s.id))
|
||||
}}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<input className="input" value={name} placeholder="New series name" disabled={busy}
|
||||
style={{ flex: 1 }} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="pill" disabled={busy || !name.trim()} style={{ fontSize: '0.72rem' }}
|
||||
onClick={() => run(async () => {
|
||||
await api.admin.createEventSeries({ name: name.trim() })
|
||||
setName('')
|
||||
})}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryChip({ entry }) {
|
||||
const projected = isProjected(entry)
|
||||
const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="sans"
|
||||
title={`${entry.title} — ${localTime(entry.scheduledFor, entry.timezone)} ${entry.timezone}${projected ? ' (forecast)' : ` — ${runStatusWord(entry.status)}`}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.7rem',
|
||||
padding: '1px 4px',
|
||||
marginBottom: 2,
|
||||
borderRadius: 3,
|
||||
borderLeft: `2px ${projected ? 'dashed' : 'solid'} ${STATUS_COLOR[entry.status] || 'var(--accent, #8fc79a)'}`,
|
||||
background: projected ? 'transparent' : 'rgba(255,255,255,0.05)',
|
||||
opacity: projected ? 0.7 : 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
<span className="dim">{localTime(entry.scheduledFor, entry.timezone)}</span> {entry.title}
|
||||
{entry.waitingSteps > 0 && <span style={{ color: '#d9c184' }}> ●</span>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
84
client/src/routes/player/PlayerEvents.jsx
Normal file
84
client/src/routes/player/PlayerEvents.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// This account's event participation (EVENTS.md §J, Phase 14a).
|
||||
//
|
||||
// **The screen's one real design decision is what an unranked row says.** A run
|
||||
// whose participants were collected but whose results have not been published
|
||||
// has a score and no rank, and that is a real state rather than an error — it is
|
||||
// the same state the admin run console has shown since Phase 10. Rendering "—"
|
||||
// with nothing explaining it would read as a bug; the row says "not published",
|
||||
// which is a fact about the event rather than about the reader.
|
||||
//
|
||||
// The list is keyset-paged on the participation row's own id, not offset-paged:
|
||||
// it gains a row every time the reader attends something.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventDateTime } from '../../lib/eventCalendar.js'
|
||||
|
||||
const PAGE = 25
|
||||
|
||||
export default function PlayerEvents() {
|
||||
const [pages, setPages] = useState([])
|
||||
const [more, setMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const result = await api.player.eventHistory({ limit: PAGE })
|
||||
setPages([result.entries || []])
|
||||
setMore((result.entries || []).length === PAGE)
|
||||
return result
|
||||
}, [])
|
||||
const { loading, error } = useAsync(load)
|
||||
|
||||
const entries = pages.flat()
|
||||
|
||||
const loadMore = async () => {
|
||||
const last = entries[entries.length - 1]
|
||||
if (!last) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await api.player.eventHistory({ limit: PAGE, before: last.id })
|
||||
setPages((p) => [...p, result.entries || []])
|
||||
setMore((result.entries || []).length === PAGE)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message="Could not load your event history." />
|
||||
if (loading) return <Loading />
|
||||
if (entries.length === 0) {
|
||||
return <EmptyState>You have not taken part in an event yet.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="panel" style={{ padding: '16px 20px', display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 240 }}>
|
||||
<Link to={`/site/events/${e.slug}?run=${e.runId}`} style={{ fontSize: '1.05rem' }}>
|
||||
{e.title}
|
||||
</Link>
|
||||
<div className="dim sans" style={{ fontSize: '0.82rem', marginTop: 4 }}>
|
||||
{eventDateTime(e.scheduledFor, e.timezone)}
|
||||
{e.seriesName && ` · ${e.seriesName}`}
|
||||
</div>
|
||||
</span>
|
||||
<span style={{ textAlign: 'right', minWidth: 140 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)' }}>
|
||||
{e.rank != null ? `Rank ${e.rank}` : <span className="dim">Results not published</span>}
|
||||
</div>
|
||||
<div className="dim sans" style={{ fontSize: '0.82rem' }}>Score {e.score}</div>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{more && (
|
||||
<button className="btn" onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,11 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
|
||||
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
|
||||
// The settings row's own icon: a bell would make the two rows read as the same
|
||||
// destination twice, which is exactly the confusion the split was meant to end.
|
||||
// Participation history (Phase 14a). A calendar rather than a trophy: the row
|
||||
// is every event this account attended, ranked or not, and most of them will
|
||||
// never have a result published against them at all.
|
||||
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /></Icon>
|
||||
|
||||
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
@@ -51,6 +56,7 @@ const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" />
|
||||
// UO module registers it again at `/player/uo/characters`, in this position,
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/events', label: 'Events', icon: IconCalendar },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
|
||||
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
|
||||
|
||||
190
client/src/routes/public/EventPage.jsx
Normal file
190
client/src/routes/public/EventPage.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
// One event's public page (EVENTS.md § API surface, Phase 14a).
|
||||
//
|
||||
// The storyline, its arc, what is live, what is next, what happened recently,
|
||||
// and a results table once an occurrence has published one.
|
||||
//
|
||||
// **`?run=` is read from the URL and passed straight through**, because that is
|
||||
// what an announcement's link carries. The page lives at the definition's slug —
|
||||
// one stable address, so a link posted in Discord survives a retitle — and the
|
||||
// occurrence has to be in the query string or a mail about last Friday's
|
||||
// invasion would open next Friday's.
|
||||
//
|
||||
// **The error is checked before the form.** Phase 13 found the inverse of this
|
||||
// on the admin editor: `if (loading || !form) return <Loading/>` above the error
|
||||
// branch left a failed load spinning for ever with nothing on screen naming the
|
||||
// problem. Order matters, and the order is error first.
|
||||
|
||||
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
|
||||
|
||||
export default function EventPage() {
|
||||
const { slug } = useParams()
|
||||
const [params] = useSearchParams()
|
||||
const run = params.get('run')
|
||||
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<ErrorState message="That event could not be found." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<Loading />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const event = data.event
|
||||
const headline = event.current || event.next
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow={event.series ? event.series.name : 'Event'}
|
||||
title={event.title}
|
||||
lead={event.summary || ''}
|
||||
/>
|
||||
|
||||
{event.series && (
|
||||
<p className="sans" style={{ marginTop: -12 }}>
|
||||
<Link to={`/site/events/series/${event.series.slug}`}>Part of {event.series.name}</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The one fact a visitor came for, before the storyline rather than
|
||||
after it: whether it is happening now, and if not, when it next is. */}
|
||||
<div
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '18px 22px',
|
||||
marginBottom: 24,
|
||||
borderColor: event.live ? '#8fc79a' : undefined,
|
||||
}}
|
||||
>
|
||||
{event.live ? (
|
||||
<>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ color: '#8fc79a', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}
|
||||
>
|
||||
Happening now
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
||||
{/* The phase LABEL, and only while it is live. The plan behind
|
||||
the event is never published. */}
|
||||
{event.current.phase || 'Under way'}
|
||||
</div>
|
||||
</>
|
||||
) : event.next ? (
|
||||
<>
|
||||
<div className="sans dim" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}>
|
||||
Next
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
||||
{eventDateTime(event.next.scheduledFor, event.next.timezone)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="dim">Nothing scheduled at the moment.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.body && (
|
||||
<article
|
||||
className="panel"
|
||||
style={{ padding: 28, marginBottom: 24 }}
|
||||
// Sanitized on write, the treatment a wiki page and a forum post get.
|
||||
dangerouslySetInnerHTML={{ __html: event.body }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{event.results && (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Results
|
||||
</h2>
|
||||
<p className="dim sans" style={{ marginTop: -6, fontSize: '0.85rem' }}>
|
||||
{eventDateTime(event.results.scheduledFor, event.timezone)}
|
||||
</p>
|
||||
{event.results.participants.length === 0 ? (
|
||||
<EmptyState>Results were published with nobody recorded.</EmptyState>
|
||||
) : (
|
||||
<table className="table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>#</th>
|
||||
<th>Who</th>
|
||||
<th style={{ width: 120, textAlign: 'right' }}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.results.participants.map((p, i) => (
|
||||
<tr key={`${p.name || 'anon'}-${i}`}>
|
||||
<td>{p.rank ?? '—'}</td>
|
||||
{/* A module supplies a display name in `meta` or it does
|
||||
not; the member key is never published, so there is
|
||||
genuinely nothing else to render. */}
|
||||
<td>{p.name || <span className="dim">Unnamed</span>}</td>
|
||||
<td style={{ textAlign: 'right' }}>{p.score}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Occurrences title="Coming up" list={event.upcoming} slug={event.slug} timezone={event.timezone} />
|
||||
<Occurrences title="Previously" list={event.past} slug={event.slug} timezone={event.timezone} past />
|
||||
|
||||
{!headline && event.past.length === 0 && (
|
||||
<EmptyState>This event has not been scheduled yet.</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function Occurrences({ title, list, slug, timezone, past = false }) {
|
||||
if (!list || list.length === 0) return null
|
||||
return (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{list.map((o) => (
|
||||
<li key={o.runId} className="panel" style={{ padding: '12px 18px', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 220 }}>{eventDateTime(o.scheduledFor, o.timezone || timezone)}</span>
|
||||
<span className="dim sans" style={{ fontSize: '0.78rem' }}>{statusWord(o.status, o.scheduledFor)}</span>
|
||||
{/* Only a past occurrence gets its own link, and only when it has
|
||||
results: on any other, `?run=` would change nothing a reader
|
||||
could see. */}
|
||||
{past && o.resultsPublishedAt && (
|
||||
<Link className="sans" style={{ fontSize: '0.78rem' }} to={`/site/events/${slug}?run=${o.runId}`}>
|
||||
Results
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
client/src/routes/public/EventSeries.jsx
Normal file
78
client/src/routes/public/EventSeries.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
// One arc (EVENTS.md §I, Phase 14a).
|
||||
//
|
||||
// **The arc is the thing the tooling this replaces could not express at all.**
|
||||
// A WordPress calendar plugin has no series field, so "Royal Spy Mission → Risky
|
||||
// Partner → Message From the Void" existed only in a GM's head and in whatever
|
||||
// the forum post said. This page is that continuity, in the order an editor
|
||||
// arranged it — which is why the events are numbered rather than dated: an arc
|
||||
// has an order, and its parts may be months apart or run out of sequence.
|
||||
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
export default function EventSeries() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.publicEventSeries(slug), [slug])
|
||||
|
||||
// Error first, then loading — the order Phase 13 had to fix on the admin
|
||||
// editor, where a failed load sat behind a spinner that never stopped.
|
||||
if (error) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<ErrorState message="That series could not be found." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<Loading />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const series = data.series
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader eyebrow="Series" title={series.name} lead={series.description || ''} />
|
||||
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{series.events.map((e, i) => (
|
||||
<li key={e.slug}>
|
||||
<Link to={`/site/events/${e.slug}`} style={{ textDecoration: 'none' }}>
|
||||
<div className="panel" style={{ padding: '18px 22px', display: 'flex', gap: 18 }}>
|
||||
<span
|
||||
className="display"
|
||||
style={{ color: 'var(--accent)', fontSize: '1.4rem', minWidth: 36, textAlign: 'right' }}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>
|
||||
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
{e.title}
|
||||
</span>
|
||||
{e.summary && <p style={{ margin: '6px 0 0', color: 'var(--text)' }}>{e.summary}</p>}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="sans" style={{ marginTop: 24 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
131
client/src/routes/public/Events.jsx
Normal file
131
client/src/routes/public/Events.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// The public event calendar (EVENTS.md §I, Phase 14a).
|
||||
//
|
||||
// **A list, not a month grid.** The admin calendar draws a grid because an
|
||||
// operator's question is "what does this month look like" — coverage, clashes,
|
||||
// the gap on the third weekend. A visitor's question is "what is on, and when is
|
||||
// the next one", which a chronological list answers in one glance and a grid
|
||||
// answers by making them count squares. Same data, different question.
|
||||
//
|
||||
// **A projection is drawn differently from a run, and the reason is the
|
||||
// operator's reason one tier along.** Past the materialisation horizon there is
|
||||
// no row: nothing is committed to, nothing can be cancelled, and a forecast
|
||||
// rendered identically to a booking would be the page promising something the
|
||||
// server has not. It is dashed and labelled "expected".
|
||||
//
|
||||
// The date heading is the READER's day and the time beside each entry is the
|
||||
// EVENT's own zone. That split is §I's: the shard's evening is what "8pm" means
|
||||
// to everyone reading it, but "this month" is the month the reader is living in.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventTime, readerDayLabel, statusWord } from '../../lib/eventCalendar.js'
|
||||
|
||||
export default function Events() {
|
||||
const { loading, error, data } = useAsync(() => api.publicEvents())
|
||||
const entries = data?.entries || []
|
||||
|
||||
// Grouped by the reader's own day, in order. The server already sorted by
|
||||
// instant, so this preserves that order rather than re-sorting.
|
||||
const days = []
|
||||
for (const entry of entries) {
|
||||
const label = readerDayLabel(entry.scheduledFor)
|
||||
const last = days[days.length - 1]
|
||||
if (last && last.label === label) last.entries.push(entry)
|
||||
else days.push({ label, entries: [entry] })
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow="What's on"
|
||||
title="Events"
|
||||
lead="Everything scheduled, live and recently finished. Times are shown in the shard's own timezone."
|
||||
/>
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the calendar right now." />}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<EmptyState>Nothing on the calendar just yet — check back soon.</EmptyState>
|
||||
)}
|
||||
{days.map((day) => (
|
||||
<div key={day.label}>
|
||||
<h2
|
||||
className="sans"
|
||||
style={{
|
||||
margin: '0 0 12px',
|
||||
fontSize: '0.74rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{day.entries.map((entry) => (
|
||||
<EventRow key={`${entry.slug}-${entry.scheduledFor}-${entry.kind}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ entry }) {
|
||||
const projected = entry.kind === 'projected'
|
||||
const body = (
|
||||
<div
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 16,
|
||||
flexWrap: 'wrap',
|
||||
// The whole visual difference between a booking and a forecast, and it
|
||||
// is deliberately not subtle.
|
||||
borderStyle: projected ? 'dashed' : undefined,
|
||||
opacity: projected ? 0.72 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="sans" style={{ fontWeight: 700, color: 'var(--accent)', minWidth: 96 }}>
|
||||
{eventTime(entry.scheduledFor, entry.timezone)}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
{entry.seriesName && (
|
||||
<span className="dim" style={{ marginLeft: 10, fontSize: '0.9rem' }}>
|
||||
{entry.seriesName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
color: entry.live ? '#8fc79a' : 'var(--muted)',
|
||||
fontWeight: entry.live ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{projected ? 'Expected' : statusWord(entry.status, entry.scheduledFor)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// A projection has no page of its own worth linking to any differently — the
|
||||
// event page IS the definition's — so both link to the same place. It is the
|
||||
// OCCURRENCE that does not exist yet, not the event.
|
||||
return <Link to={`/site/events/${entry.slug}`} style={{ textDecoration: 'none' }}>{body}</Link>
|
||||
}
|
||||
@@ -252,3 +252,40 @@ test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
|
||||
// ── Public events (Phase 14a) ───────────────────────────────────────────
|
||||
//
|
||||
// The one shape worth pinning is `?run=`: it is what an announcement's link
|
||||
// carries, and a client that dropped it would make a mail about last Friday's
|
||||
// occurrence open next Friday's.
|
||||
|
||||
test('the public calendar asks for no window at all by default', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.publicEvents()
|
||||
// The server defaults to now through a month out, so the first render need
|
||||
// not compute two ISO instants before it can ask for anything.
|
||||
assert.equal(calls[0].url, '/api/v1/public/events')
|
||||
})
|
||||
|
||||
test('an event page carries the run when one was named, and not when it was not', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
|
||||
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion', 3692)
|
||||
assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
|
||||
})
|
||||
|
||||
test('an event slug is URL-encoded on every public path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEventSeries('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
|
||||
})
|
||||
|
||||
test('participation history takes a keyset cursor, never an offset', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.player.eventHistory({ limit: 25, before: 900 })
|
||||
assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
|
||||
})
|
||||
|
||||
910
client/test/eventAuthoring.test.js
Normal file
910
client/test/eventAuthoring.test.js
Normal file
@@ -0,0 +1,910 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
isParked,
|
||||
lastStartedSeqOf,
|
||||
formFromDefinition,
|
||||
payloadFromForm,
|
||||
parseParams,
|
||||
blankStep,
|
||||
blankPhase,
|
||||
describeLogLine,
|
||||
logKindWord,
|
||||
runStatusWord,
|
||||
describeSchedule,
|
||||
scheduleFormFrom,
|
||||
scheduleFromForm,
|
||||
isProjected,
|
||||
blankAdvance,
|
||||
advanceFormFrom,
|
||||
advancePayload,
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTHS,
|
||||
ADVANCE_KINDS,
|
||||
blankWhere,
|
||||
whereFormFrom,
|
||||
paramsRenderable,
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
PARAM_FORM,
|
||||
PARAM_JSON,
|
||||
} 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('`listed` round-trips, and an unlisted event is not quietly re-listed', () => {
|
||||
// The trap this guards is `||` where `??` is meant. A definition an operator
|
||||
// deliberately unlisted sends `listed: false`, and `event?.listed || true`
|
||||
// would put it back on the public calendar on the author's next save — a
|
||||
// surprise event announced by a typo fix.
|
||||
const unlisted = payloadFromForm(
|
||||
formFromDefinition({ title: 'Invasion', listed: false, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||||
)
|
||||
assert.equal(unlisted.payload.listed, false)
|
||||
|
||||
const listed = payloadFromForm(
|
||||
formFromDefinition({ title: 'Invasion', listed: true, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||||
)
|
||||
assert.equal(listed.payload.listed, true)
|
||||
})
|
||||
|
||||
test('a new definition defaults to listed', () => {
|
||||
// The column's own default, and the ordinary case: unlisting is the
|
||||
// deliberate act, not listing.
|
||||
const fresh = payloadFromForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
|
||||
assert.equal(fresh.payload.listed, true)
|
||||
})
|
||||
|
||||
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}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The one log line core did not compose (Phase 15) ──────────────────────
|
||||
|
||||
test('a module detail line renders the module keys, not the kind id', () => {
|
||||
// The failure this guards is subtle and total: `step.detail` falling to the
|
||||
// default renders the literal string "step.detail", which is the reporting
|
||||
// channel existing and showing nothing — exactly what it was built to fix.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
|
||||
})
|
||||
|
||||
assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
|
||||
assert.match(text, /uo\.item\.grant/)
|
||||
assert.match(text, /granted: 8/)
|
||||
assert.match(text, /missed: 4/)
|
||||
assert.match(text, /bank full/)
|
||||
})
|
||||
|
||||
test('a module detail is rendered generically, whatever a module puts in it', () => {
|
||||
// Core does not interpret these keys and neither does the renderer — a switch
|
||||
// here would be the browser learning one module vocabulary, which is the thing
|
||||
// the module system exists to prevent. So an unfamiliar shape still reads.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
|
||||
})
|
||||
assert.ok(!text.includes('undefined'), text)
|
||||
assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
|
||||
assert.match(text, /eu 3/)
|
||||
assert.match(text, /dryRun: false/, 'false is a value, not an absence')
|
||||
})
|
||||
|
||||
test('a long module detail stays one line', () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
|
||||
})
|
||||
assert.match(text, /and 35 more/)
|
||||
assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
|
||||
})
|
||||
|
||||
test('a module detail with nothing in it still reads as a sentence', () => {
|
||||
const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(!text.includes('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')
|
||||
})
|
||||
|
||||
|
||||
// ── The schedule form (Phase 4) ─────────────────────────────────────
|
||||
//
|
||||
// The form is the whole argument against cron: a closed set of four shapes has a
|
||||
// dropdown, and a dropdown can be proofread. What is checked here is that the
|
||||
// round trip through the form does not quietly change what the author wrote —
|
||||
// the server would refuse a malformed schedule, but it cannot refuse a
|
||||
// well-formed one that says something the author did not mean.
|
||||
|
||||
test('a schedule survives the round trip through the form unchanged', () => {
|
||||
for (const schedule of [
|
||||
{ kind: 'manual' },
|
||||
{ kind: 'once', at: '2026-10-31T20:00' },
|
||||
{ kind: 'weekly', days: ['monday', 'friday'], time: '20:00' },
|
||||
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
|
||||
]) {
|
||||
const form = scheduleFormFrom(schedule)
|
||||
assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule))
|
||||
}
|
||||
})
|
||||
|
||||
test('switching kind keeps the other shapes fields, and sends only the chosen one', () => {
|
||||
// An author who clicks Weekly, then Monthly, then back must not find the days
|
||||
// they picked gone — but the request body must still be a single clean shape,
|
||||
// not a union of everything they touched.
|
||||
const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' }
|
||||
const sent = scheduleFromForm(form)
|
||||
assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday'])
|
||||
assert.equal(form.scheduleDays.includes('friday'), true)
|
||||
})
|
||||
|
||||
test('formFromDefinition carries the whole schedule, not only its kind', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'Fishing contest',
|
||||
timezone: 'Europe/Berlin',
|
||||
spec: {
|
||||
schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
},
|
||||
})
|
||||
assert.equal(form.scheduleKind, 'monthly')
|
||||
assert.equal(form.scheduleNth, '-1')
|
||||
assert.equal(form.scheduleWeekday, 'friday')
|
||||
assert.equal(form.scheduleTime, '19:30')
|
||||
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.schedule, {
|
||||
kind: 'monthly',
|
||||
nth: -1,
|
||||
weekday: 'friday',
|
||||
time: '19:30',
|
||||
})
|
||||
})
|
||||
|
||||
test('a definition with no schedule at all reads as manual rather than as broken', () => {
|
||||
const form = formFromDefinition({ title: 'x', spec: { phases: [] } })
|
||||
assert.equal(form.scheduleKind, 'manual')
|
||||
assert.deepEqual(scheduleFromForm(form), { kind: 'manual' })
|
||||
})
|
||||
|
||||
test('every schedule describes as a sentence, and a half-built one says what is missing', () => {
|
||||
assert.match(describeSchedule({ kind: 'manual' }), /by hand/)
|
||||
assert.equal(
|
||||
describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
|
||||
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
|
||||
)
|
||||
assert.equal(
|
||||
describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
|
||||
'The last Friday of every month at 19:30 (Asia/Kolkata)',
|
||||
)
|
||||
// Half-built is the state the preview spends most of its life in — an author
|
||||
// is typing. It must prompt, never render "undefined".
|
||||
for (const partial of [
|
||||
{ kind: 'weekly', days: [], time: '20:00' },
|
||||
{ kind: 'weekly', days: ['friday'], time: '' },
|
||||
{ kind: 'monthly', nth: 1, weekday: '', time: '19:00' },
|
||||
{ kind: 'once', at: '' },
|
||||
]) {
|
||||
const text = describeSchedule(partial, 'UTC')
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`)
|
||||
assert.match(text, /choose|no date/i)
|
||||
}
|
||||
})
|
||||
|
||||
test('the weekday and nth vocabularies match the server', () => {
|
||||
// Verbatim `events/recurrence.js`. A client list that drifted would offer a
|
||||
// value the server refuses, which is exactly the class of failure this file
|
||||
// exists to catch.
|
||||
assert.deepEqual(WEEKDAYS, [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||||
])
|
||||
assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1])
|
||||
})
|
||||
|
||||
test('a projection is told apart from a run, because only one of them can be acted on', () => {
|
||||
assert.equal(isProjected({ kind: 'projected', runId: null }), true)
|
||||
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
|
||||
assert.equal(isProjected(null), false)
|
||||
})
|
||||
|
||||
|
||||
// -- The advance gate (Phase 5) ---------------------------------------------
|
||||
//
|
||||
// What this screen must get right is what it OFFERS. `advance` is the one
|
||||
// control in this feature whose whole point is that it overrides the engine, so
|
||||
// a button offered in a state the server refuses would be the "control that
|
||||
// answers 409 and does nothing" this feature has refused twice.
|
||||
|
||||
test('advance is offered only when the phase is waiting on its gate', () => {
|
||||
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
|
||||
const done = [{ phase: 'boss', status: 'done' }]
|
||||
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
|
||||
|
||||
// A phase with an open step is held by the STEP, and skip is its control.
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
|
||||
false,
|
||||
)
|
||||
|
||||
// A phase with no gate advances on its steps and always has.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
|
||||
// A gate already satisfied is not waiting.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
|
||||
// And a run that is not running is waiting on nothing.
|
||||
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('runControlsFor still answers with no gates or steps at all', () => {
|
||||
// The three Phase 3 controls were called with one argument for two phases, and
|
||||
// the calendar still calls it that way.
|
||||
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
|
||||
assert.equal(controls.pause, true)
|
||||
assert.equal(controls.advance, false)
|
||||
})
|
||||
|
||||
test('a gate round-trips through the form without losing the other shape', () => {
|
||||
assert.deepEqual(advanceFormFrom(null), blankAdvance())
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
|
||||
|
||||
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
|
||||
assert.equal(on.kind, 'on')
|
||||
assert.equal(on.count, 3)
|
||||
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
|
||||
|
||||
// The dropdown's three options, and the empty one is what nearly every phase
|
||||
// is — so it is first and it is not called "none".
|
||||
assert.equal(ADVANCE_KINDS[0].value, '')
|
||||
})
|
||||
|
||||
test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
|
||||
const errors = []
|
||||
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
|
||||
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
|
||||
assert.deepEqual(
|
||||
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
|
||||
{ on: 'uo.champ.boss_up', count: 2 },
|
||||
'an empty predicate is omitted, not sent as an empty object',
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
// Whether the predicate is VALID is still the server's answer, named variable
|
||||
// and all \u2014 the builder only offers what the trigger declares, and a variable
|
||||
// that has gone away comes back named from the save.
|
||||
assert.deepEqual(
|
||||
advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
|
||||
},
|
||||
'Phase 1',
|
||||
errors,
|
||||
[{ name: 'nope', type: 'int' }],
|
||||
),
|
||||
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
test('the builder coerces each literal to the type the trigger declared', () => {
|
||||
// The trap this closes: every value in an HTML input is a string, and
|
||||
// `{ cmp: 'gt', value: "5" }` against an int variable is refused by
|
||||
// engagement/conditions.js. Without this the author reads an error about JSON
|
||||
// rather than about what they typed.
|
||||
const built = advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereOp: 'or',
|
||||
whereRows: [
|
||||
{ variable: 'tier', cmp: 'gte', value: '3' },
|
||||
{ variable: 'region', cmp: 'in', value: 'Yew, Britain' },
|
||||
],
|
||||
},
|
||||
'Phase 1',
|
||||
[],
|
||||
[{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
|
||||
)
|
||||
assert.deepEqual(built.where, {
|
||||
op: 'or',
|
||||
nodes: [
|
||||
{ variable: 'tier', cmp: 'gte', value: 3 },
|
||||
{ variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
|
||||
// `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
|
||||
// and an author would have no way to know the save had done it. The condition
|
||||
// builder's own rule, and this is the same function.
|
||||
const nested = {
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
{ op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
|
||||
],
|
||||
}
|
||||
const form = whereFormFrom(nested)
|
||||
assert.equal(form.whereEditable, false)
|
||||
assert.deepEqual(form.whereRows, [])
|
||||
|
||||
const errors = []
|
||||
const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
|
||||
assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
// And the text is still the thing that can fail to parse, which is the only
|
||||
// reason this path keeps an error channel at all.
|
||||
advancePayload(
|
||||
{ kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
|
||||
'Phase 2 "Boss"',
|
||||
errors,
|
||||
)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
|
||||
})
|
||||
|
||||
test('a phase with no gate sends no `advance` key', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.equal('advance' in built.payload.spec.phases[0], false)
|
||||
})
|
||||
|
||||
test('an authored gate survives the round trip through the form', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
|
||||
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.phases[0].advance, {
|
||||
on: 'uo.champ.boss_up',
|
||||
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
count: 2,
|
||||
})
|
||||
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
|
||||
})
|
||||
|
||||
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
|
||||
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
|
||||
)
|
||||
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
|
||||
/did not count — 0 of 2/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
|
||||
/counted — 2 of 2, condition met/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
|
||||
/boss advanced by hand after 4080s: never spawned/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
|
||||
/loot advanced on its deadline after 600s/,
|
||||
)
|
||||
})
|
||||
|
||||
test("the log renders Phase 6's three kinds, and a refusal does not read as a failure", () => {
|
||||
// The distinction the whole kind exists for. An operator scanning a stopped run
|
||||
// has to be able to see that nothing is broken — the deployment simply does not
|
||||
// permit what the author asked for — and the answer differs by cause: a switch
|
||||
// for "not enabled", a number for "over the cap".
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: 'asks for 12 of "uo.creatures"; 28 of 30 is already spent this run' },
|
||||
}),
|
||||
/uo\.creature\.spawn refused: asks for 12 of "uo\.creatures"; 28 of 30 is already spent this run/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: '"Spawn creatures" is not enabled on this deployment' },
|
||||
}),
|
||||
/refused: "Spawn creatures" is not enabled/,
|
||||
)
|
||||
assert.equal(logKindWord('step.refused'), 'Refused')
|
||||
|
||||
// The caps a run was seeded with, and which switch set each — so a number on
|
||||
// the meter can be traced back to something an operator can change.
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'run.budget',
|
||||
detail: { dimensions: [{ dimension: 'uo.creatures', cap: 30, from: 'uo.creature.spawn' }] },
|
||||
}),
|
||||
/uo\.creatures capped at 30 \(uo\.creature\.spawn\)/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'run.budget', detail: { dimensions: [{ dimension: 'uo.gate.minutes', cap: null, from: null }] } }),
|
||||
/uo\.gate\.minutes capped at nothing/,
|
||||
)
|
||||
// A run with no capped dimension at all still gets a sentence rather than an
|
||||
// empty line, because an empty log entry reads as a bug.
|
||||
assert.match(describeLogLine({ kind: 'run.budget', detail: { dimensions: [] } }), /no caps apply to this run/)
|
||||
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'version.verified', detail: { versionId: 4, version: 2, by: 1 } }),
|
||||
/Version 2 passed its dry run — scheduled occurrences may start/,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
// ── Step params as a form (Phase 13) ──────────────────────────────
|
||||
//
|
||||
// The form is not a boundary either — `events/spec.js` still decides what may be
|
||||
// saved. What is tested here is the thing that would be wrong SILENTLY: a form
|
||||
// that drops a param it cannot draw, or writes a value the author never typed.
|
||||
|
||||
const spawn = {
|
||||
id: 'test.spawn',
|
||||
label: 'Spawn',
|
||||
params: [
|
||||
{ name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
|
||||
{ name: 'count', type: 'int', required: true, example: 8 },
|
||||
{ name: 'tame', type: 'boolean', required: false, example: false },
|
||||
{ name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const stepWith = (params, over = {}) => ({
|
||||
actionId: 'test.spawn',
|
||||
paramsText: JSON.stringify(params, null, 2),
|
||||
...over,
|
||||
})
|
||||
|
||||
test('a step whose params the form can hold opens as a form', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
|
||||
assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
|
||||
})
|
||||
|
||||
test('an author who chose JSON stays in JSON', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, false, 'their choice, so no reason is shown')
|
||||
})
|
||||
|
||||
test('a param the action does not declare FORCES the JSON box and says which', () => {
|
||||
// The form would render four fields and post four values, having deleted
|
||||
// `radius` — a save that looks clean and means something else. The save path
|
||||
// refuses it by name, which is what the author needs to see.
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
|
||||
})
|
||||
|
||||
test('a value no single control can hold forces the JSON box', () => {
|
||||
assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
|
||||
assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
|
||||
})
|
||||
|
||||
test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not installed/)
|
||||
})
|
||||
|
||||
test('a params box that is not JSON opens as JSON with the parse error', () => {
|
||||
const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not valid JSON/)
|
||||
})
|
||||
|
||||
test('paramsRenderable accepts a step with nothing in it', () => {
|
||||
// A brand-new step with an optional-only action, and the empty case a form
|
||||
// needs to survive before anybody has typed.
|
||||
assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
|
||||
})
|
||||
|
||||
test('setParam writes the type the param declared, not the string the input held', () => {
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
|
||||
creature: 'orc',
|
||||
count: 8,
|
||||
tame: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('a half-typed number is kept as typed rather than turned into NaN', () => {
|
||||
// `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
|
||||
// turning `-` into NaN while somebody types would either post a value they
|
||||
// never wrote or make a negative impossible to enter. The server's type check
|
||||
// then names the param.
|
||||
const step = stepWith({ count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
|
||||
})
|
||||
|
||||
test('clearing a field REMOVES the key rather than posting an empty string', () => {
|
||||
// `checkParams` treats undefined, null and '' alike — absent — so a required
|
||||
// param left blank comes back as "is required", which is the error the author
|
||||
// needs, instead of a type complaint about "".
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
|
||||
})
|
||||
|
||||
test('setParam leaves an unparseable box alone rather than overwriting it', () => {
|
||||
// The only way to reach this is a race between the mode switch and a
|
||||
// keystroke; silently replacing the text with `{ "count": 1 }` would destroy
|
||||
// whatever the author was midway through writing.
|
||||
const step = { actionId: 'test.spawn', paramsText: '{ not json' }
|
||||
assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
|
||||
})
|
||||
|
||||
test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
|
||||
assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
|
||||
})
|
||||
|
||||
test('a datetime is sliced to what the input wants, and anything else is empty', () => {
|
||||
assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
|
||||
assert.equal(datetimeInputValue(undefined), '')
|
||||
assert.equal(datetimeInputValue(12), '')
|
||||
})
|
||||
|
||||
// ── The meter's request (Phase 13) ────────────────────────────
|
||||
|
||||
test('the price body carries the plan and nothing else', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'Invasion',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
assert.deepEqual(priceBodyFrom(form), {
|
||||
phases: [
|
||||
{ key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a step whose params do not parse is priced with none rather than dropped', () => {
|
||||
// Dropping it would move every step after it up an ordinal, so the meter's
|
||||
// "phase 2 step 3" would name a different step from the one on the screen.
|
||||
const form = {
|
||||
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
|
||||
}
|
||||
assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
|
||||
})
|
||||
|
||||
test('an empty plan is not worth pricing', () => {
|
||||
// Otherwise the meter asks the server what nothing costs on every keystroke of
|
||||
// the title field.
|
||||
assert.equal(worthPricing({ phases: [] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
|
||||
})
|
||||
89
client/test/eventCalendar.test.js
Normal file
89
client/test/eventCalendar.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
|
||||
//
|
||||
// One property matters here and it is EVENTS.md §I's: **the time beside an
|
||||
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
|
||||
// helper that quietly rendered both in the reader's zone would pass any test
|
||||
// that only ever looked at one of them, and would put an American shard's 8pm
|
||||
// event at "02:00" for a player in Berlin — true, useless, and looking like the
|
||||
// shard's own announcement was wrong.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
|
||||
|
||||
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
|
||||
// whose DATE differs between the two zones, which is what makes the split
|
||||
// observable at all.
|
||||
const INSTANT = '2026-09-12T00:00:00.000Z'
|
||||
|
||||
test('the time is rendered in the EVENT’s zone, not the reader’s', () => {
|
||||
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
|
||||
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
|
||||
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
|
||||
})
|
||||
|
||||
test('the zone is named in a form a reader recognises', () => {
|
||||
// `America/New_York` is a database identifier, not something to show a player.
|
||||
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
|
||||
})
|
||||
|
||||
test('an unknown zone falls back to UTC rather than throwing', () => {
|
||||
// `Intl` rejects an unknown identifier, and an event whose timezone column
|
||||
// holds a typo must still render.
|
||||
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
|
||||
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
|
||||
})
|
||||
|
||||
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
|
||||
assert.equal(readerDayLabel('not a date'), '')
|
||||
assert.equal(eventDateTime('not a date', 'UTC'), '')
|
||||
})
|
||||
|
||||
test('the day label is the reader’s own day, whatever the event’s zone', () => {
|
||||
// Two entries at the same instant in different event zones are filed under one
|
||||
// heading, which is what makes a chronological list group correctly.
|
||||
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
|
||||
const label = readerDayLabel(INSTANT)
|
||||
assert.ok(label.length > 0)
|
||||
// The instant's UTC date is the 12th and New York's is the 11th; the label
|
||||
// must not carry a zone at all, because it is neither of theirs.
|
||||
assert.equal(/UTC|New York/.test(label), false)
|
||||
})
|
||||
|
||||
test('eventDateTime carries the day and the zone together', () => {
|
||||
const text = eventDateTime(INSTANT, 'America/New_York')
|
||||
assert.match(text, /New York$/)
|
||||
assert.match(text, /20:00/)
|
||||
})
|
||||
|
||||
// ── The status word ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
|
||||
// run four days out that an operator had cancelled. The server publishes
|
||||
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
|
||||
// event — but they do not share one English sentence, so the tense follows the
|
||||
// clock rather than the status.
|
||||
|
||||
const NOW = Date.parse('2026-09-08T12:00:00Z')
|
||||
|
||||
test('a cancelled occurrence in the future reads "Cancelled"', () => {
|
||||
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
|
||||
})
|
||||
|
||||
test('a cancelled occurrence in the past reads "Did not happen"', () => {
|
||||
// Which is also the honest word for the failed and missed runs folded into
|
||||
// `cancelled` on the way out.
|
||||
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
|
||||
})
|
||||
|
||||
test('the other three words do not depend on the clock at all', () => {
|
||||
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
|
||||
assert.equal(statusWord('live', at, NOW), 'Happening now')
|
||||
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
|
||||
assert.equal(statusWord('completed', at, NOW), 'Finished')
|
||||
}
|
||||
})
|
||||
|
||||
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
|
||||
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
|
||||
})
|
||||
@@ -1774,9 +1774,18 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
|
||||
-- per subject".
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
-- The CHANNEL the cooldown is about, added in Phase 11b after the live walk.
|
||||
-- Without it a rule naming two channels delivers on exactly ONE of them: the
|
||||
-- claim runs inside the engine's per-channel loop, `inapp` is ranked first on
|
||||
-- purpose (so push can reference its inbox row), and every later channel is
|
||||
-- then reported as cooled. Phase 11b's decision 8 requires the letter and the
|
||||
-- inbox item to fire together, so the cooldown is per delivery, not per
|
||||
-- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason:
|
||||
-- the channel set is data a module can extend.
|
||||
channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key),
|
||||
PRIMARY KEY (rule_id, user_id, subject_key, channel),
|
||||
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- So a prune worker can drop rows older than the longest configured cooldown.
|
||||
@@ -1785,6 +1794,29 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
INDEX idx_engc_sweep (last_fired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Widen the key on a deployment that already has the table. Two statements, and
|
||||
-- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY
|
||||
-- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already
|
||||
-- carries the new one is an error, not a no-op, so replaying this file on every
|
||||
-- boot would fail the whole schema after the first run. The guard reads the key
|
||||
-- itself out of information_schema rather than the column's existence, because
|
||||
-- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not.
|
||||
--
|
||||
-- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user,
|
||||
-- subject) that expires on its own interval. That is the right trade against
|
||||
-- deleting them: a cooldown that outlives its rewrite costs at most one delayed
|
||||
-- notification, and dropping the table would let a bounce storm through.
|
||||
ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT '';
|
||||
SET @engc_key_has_channel := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns'
|
||||
AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel'
|
||||
);
|
||||
SET @sql := IF(@engc_key_has_channel = 0,
|
||||
'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)',
|
||||
'DO 0');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
|
||||
-- (rule, user, channel) occurrence of an event.
|
||||
CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
@@ -1818,7 +1850,10 @@ CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
|
||||
-- every scheduled row for that (rule, user, house).
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status),
|
||||
-- Phase 14. The sweep is `status IN (terminal) AND created_at < ?`, and
|
||||
-- `idx_engo_due` cannot serve it: its second column is `due_at`.
|
||||
INDEX idx_engo_sweep (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
|
||||
@@ -1845,7 +1880,11 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
|
||||
INDEX idx_engs_user (user_id, created_at),
|
||||
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
|
||||
-- an index range scan rather than a table scan: it runs once per rule per event.
|
||||
INDEX idx_engs_rule_window (rule_id, created_at)
|
||||
INDEX idx_engs_rule_window (rule_id, created_at),
|
||||
-- Phase 14's retention sweep deletes by age alone, so it needs `created_at`
|
||||
-- LEADING. Every index above has it in second position, which serves a
|
||||
-- per-rule or per-user window and is useless to a whole-table horizon.
|
||||
INDEX idx_engs_sweep (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
|
||||
@@ -1905,6 +1944,14 @@ CREATE TABLE IF NOT EXISTS engagement_templates (
|
||||
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
|
||||
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
|
||||
|
||||
-- Phase 14 (retention). The two sweep indexes, for deployments whose tables
|
||||
-- predate them. `IF NOT EXISTS` on an index is MariaDB-only and already used
|
||||
-- above (`idx_wiki_search`), so this needs no INFORMATION_SCHEMA guard like the
|
||||
-- cooldown primary-key change did -- that one needed one because MariaDB has no
|
||||
-- conditional form of a PRIMARY KEY change, not because indexes lack one.
|
||||
ALTER TABLE engagement_outbox ADD INDEX IF NOT EXISTS idx_engo_sweep (status, created_at);
|
||||
ALTER TABLE engagement_sends ADD INDEX IF NOT EXISTS idx_engs_sweep (created_at);
|
||||
|
||||
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
|
||||
--
|
||||
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
|
||||
@@ -2036,3 +2083,616 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
||||
INDEX idx_engsup_created (created_at),
|
||||
INDEX idx_engsup_reason (reason, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── The Event System (EVENTS.md §D — Phase 1) ──────────────────────────────
|
||||
|
||||
-- Six of the eleven core tables land here: the ones that do not depend on the
|
||||
-- module contract. The rest arrive with the phases that give them a writer
|
||||
-- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5,
|
||||
-- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in
|
||||
-- P8 (below) and `event_run_participants` in P10.
|
||||
--
|
||||
-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
|
||||
-- action id, a scope, a resource kind and a budget dimension are all opaque
|
||||
-- strings core stores and never interprets (§C).
|
||||
|
||||
-- The arc. Definitions optionally belong to one, and the series is what carries
|
||||
-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From
|
||||
-- the Void" is a thing the tooling this replaces cannot express at all.
|
||||
--
|
||||
-- The table lands in Phase 1 because `event_definitions.series_id` points at it;
|
||||
-- the routes that create and order one are Phase 4's, where the calendar makes
|
||||
-- an arc visible.
|
||||
CREATE TABLE IF NOT EXISTS event_series (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
slug VARCHAR(160) NOT NULL,
|
||||
description TEXT NULL,
|
||||
-- Where this series sits among the others on the calendar. Not a position
|
||||
-- WITHIN the series: a definition's place in its arc is `event_definitions`'
|
||||
-- own `series_order` below, because that is the column an editor drags.
|
||||
ordering INT NOT NULL DEFAULT 0,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evser_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The thing that is listed, searched, scheduled and audited.
|
||||
--
|
||||
-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an
|
||||
-- admin publishes their own work, so there is nobody to submit it to, and a
|
||||
-- review state nobody uses is a state every query has to remember anyway.
|
||||
--
|
||||
-- `owner_module` is NULLable and is the module that SHIPPED this definition as
|
||||
-- content, not the module whose actions its steps call — a definition may call
|
||||
-- three modules' verbs and belong to none of them. NULL means an operator
|
||||
-- authored it here, which is the ordinary case.
|
||||
--
|
||||
-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value
|
||||
-- (`invasion:{region}`), because it is rendered from a run's own params at
|
||||
-- materialisation (§E). A flat definition-id key would wrongly stop one
|
||||
-- definition running in two regions at once.
|
||||
--
|
||||
-- `current_version_id` carries NO foreign key, deliberately, and it is the one
|
||||
-- column in this group without one: `event_versions.definition_id` already
|
||||
-- points back here, and a second FK in the other direction makes the pair a
|
||||
-- chicken and an egg on insert.
|
||||
CREATE TABLE IF NOT EXISTS event_definitions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) NOT NULL,
|
||||
summary VARCHAR(500) NULL,
|
||||
-- The storyline. Sanitized HTML, the same treatment a wiki page gets.
|
||||
body MEDIUMTEXT NULL,
|
||||
image_url VARCHAR(500) NULL,
|
||||
owner_module VARCHAR(64) NULL,
|
||||
state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
|
||||
current_version_id INT NULL,
|
||||
-- The WORKING COPY of the spec - phases and their steps - as the author last
|
||||
-- saved it. §D's column list does not name it because §D describes what a
|
||||
-- PUBLISHED event is made of, and a version row is where a spec ends up. But
|
||||
-- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning")
|
||||
-- has to mean the draft lives somewhere, and it cannot be an `event_versions`
|
||||
-- row: that table is immutable and a run pins one, so a mutable unpublished
|
||||
-- row in it would be the exact thing versioning exists to prevent. Publishing
|
||||
-- copies this column into a version and leaves it here as the next draft.
|
||||
spec JSON NOT NULL,
|
||||
series_id INT NULL,
|
||||
series_order INT NOT NULL DEFAULT 0,
|
||||
concurrency_key VARCHAR(190) NULL,
|
||||
-- The grace window (§E). A schedule that passed this many seconds ago while the
|
||||
-- process was down is `missed`, never a late silent start.
|
||||
grace_seconds INT NOT NULL DEFAULT 900,
|
||||
-- IANA, and it belongs to the EVENT rather than to the viewer: every listing
|
||||
-- this replaces is written in the shard's local zone, and a recurrence computed
|
||||
-- in UTC puts a Friday-8pm event at 7pm for half the year.
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
created_by INT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evdef_slug (slug),
|
||||
-- The admin list's default ordering, and the public calendar's filter.
|
||||
INDEX idx_evdef_state (state, updated_at),
|
||||
INDEX idx_evdef_series (series_id, series_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- An immutable snapshot of a whole definition spec: phases, steps, schedule,
|
||||
-- conditions, announcements. A run pins one, and that pin is the entire reason
|
||||
-- this table exists — it is what makes a run reproducible, and an audit
|
||||
-- answerable, after the definition has been edited underneath it.
|
||||
--
|
||||
-- Nothing updates a row here. Editing a `ready` definition creates the NEXT
|
||||
-- version on publish; a live run keeps the version it pinned and is unaffected
|
||||
-- (EVENTS.md "Versioning, and editing a live event").
|
||||
CREATE TABLE IF NOT EXISTS event_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
version INT NOT NULL,
|
||||
spec JSON NOT NULL,
|
||||
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
published_by INT NULL,
|
||||
CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Two publishes racing for version 4 is one 1062, not two rows called 4.
|
||||
UNIQUE KEY uq_evver_def_version (definition_id, version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One occurrence of one definition, in one scope.
|
||||
--
|
||||
-- The UNIQUE key below — not the claim — is what makes "one run per occurrence
|
||||
-- per scope" true (§E). The claim decides WHO advances an occurrence; this index
|
||||
-- is what stops two of them existing. `scope` is inside the key so a worldwide
|
||||
-- event fans out to many servers without colliding with itself, and it is
|
||||
-- module-opaque: core stores the string and never parses it.
|
||||
--
|
||||
-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was
|
||||
-- COMPUTED in (Phase 4); what is stored is the instant.
|
||||
--
|
||||
-- `health` is a separate column from `status` because a run can be genuinely
|
||||
-- running and degraded at once — announcements landing, world writes parked —
|
||||
-- and one column cannot say both. `cleanup_status` is separate for the mirror
|
||||
-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather
|
||||
-- than being held open, and stays on the admin screen until a human resolves it.
|
||||
--
|
||||
-- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run
|
||||
-- whose pinned spec had been deleted could not be explained afterwards, which is
|
||||
-- the one thing this table is for.
|
||||
CREATE TABLE IF NOT EXISTS event_runs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
version_id INT NOT NULL,
|
||||
-- Module-opaque, and '' rather than NULL for the single-scope case: it is part
|
||||
-- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL
|
||||
-- scope would silently permit two runs of one occurrence.
|
||||
scope VARCHAR(190) NOT NULL DEFAULT '',
|
||||
status ENUM('scheduled','starting','running','paused','ending',
|
||||
'completed','cancelled','failed','missed')
|
||||
NOT NULL DEFAULT 'scheduled',
|
||||
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
|
||||
cleanup_status ENUM('not_required','pending','complete','incomplete')
|
||||
NOT NULL DEFAULT 'not_required',
|
||||
current_phase VARCHAR(64) NULL,
|
||||
scheduled_for DATETIME NOT NULL,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
concurrency_key VARCHAR(190) NULL, -- rendered from this run's params
|
||||
params JSON NULL,
|
||||
-- A rehearsal dispatches for real but is excluded from the public calendar and
|
||||
-- from participation history. Declared here in Phase 1 so the column exists
|
||||
-- before anything can create a run without it.
|
||||
rehearsal TINYINT(1) NOT NULL DEFAULT 0,
|
||||
started_at DATETIME NULL,
|
||||
ended_at DATETIME NULL,
|
||||
claimed_by VARCHAR(64) NULL,
|
||||
claim_expires_at DATETIME NULL,
|
||||
started_by INT NULL,
|
||||
last_error VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id),
|
||||
CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
|
||||
-- The runner's materialise/advance scan: due runs by status.
|
||||
INDEX idx_evrun_due (status, scheduled_for),
|
||||
-- The admin run list, newest first, and the per-definition history.
|
||||
INDEX idx_evrun_def (definition_id, scheduled_for),
|
||||
-- Phase 2's overlap check. NULL keys are skipped by the index, which is right:
|
||||
-- a definition with no key never contends.
|
||||
INDEX idx_evrun_concurrency (concurrency_key, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The work queue: one action invocation, claimed with the outbox's
|
||||
-- compare-and-set. This is the shape `engagement_outbox` already proved, and
|
||||
-- retries, timeouts, duplicate execution and resumption are all properties of
|
||||
-- this row rather than of a scheduler's memory.
|
||||
--
|
||||
-- `idempotency_key` is minted ONCE at materialisation and does not vary by
|
||||
-- attempt (§E) — a retry re-sends the same key so the game side can recognise
|
||||
-- the repeat. It is generated by core rather than by the module because core is
|
||||
-- what guarantees its stability.
|
||||
--
|
||||
-- `action_version` records what the step was AUTHORED against. A module that
|
||||
-- bumps its action makes the step render a warning in the editor rather than
|
||||
-- dispatch a mistyped parameter.
|
||||
--
|
||||
-- `refused` is in the status set and is deliberately not `failed`: a cap breach
|
||||
-- means nothing is wrong with the system, and an author asked for more than this
|
||||
-- deployment allows.
|
||||
CREATE TABLE IF NOT EXISTS event_run_steps (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
action_id VARCHAR(96) NOT NULL,
|
||||
params JSON NULL,
|
||||
action_version INT NOT NULL DEFAULT 1,
|
||||
status ENUM('pending','running','done','failed','skipped','refused','cancelled')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
due_at DATETIME NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
-- Defaulted from the action's risk class at materialisation (§L): retry->skip
|
||||
-- for notify, retry->pause for change, retry->abort_run for irreversible.
|
||||
on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
|
||||
idempotency_key CHAR(40) NOT NULL,
|
||||
claimed_by VARCHAR(64) NULL,
|
||||
claim_expires_at DATETIME NULL,
|
||||
last_error VARCHAR(500) NULL,
|
||||
started_at DATETIME NULL,
|
||||
finished_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
-- The drain scan, exactly as written: due, pending, oldest first.
|
||||
INDEX idx_evstep_due (status, due_at),
|
||||
-- The run console: every step of one run in authored order.
|
||||
INDEX idx_evstep_run (run_id, phase, seq),
|
||||
-- Materialisation is INSERT IGNORE against this, so a tick that overruns into
|
||||
-- the next one cannot double-materialise a phase.
|
||||
UNIQUE KEY uq_evstep_slot (run_id, phase, seq)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- "Why didn't phase 3 start?" must be a query.
|
||||
--
|
||||
-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this
|
||||
-- table exists rather than the audit log being reused: an operator diagnosing a
|
||||
-- stalled phase needs to filter by kind and read structured detail, and an
|
||||
-- administrative audit of WHO published WHAT is a different question with a
|
||||
-- different retention. Both are written — the audit to `activity_log`, the
|
||||
-- diagnosis here.
|
||||
--
|
||||
-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM,
|
||||
-- because the set grows with almost every later phase and an ENUM change is a
|
||||
-- table alter this project has no migration system for.
|
||||
--
|
||||
-- The log is high-cardinality and grows per event, so it needs a retention sweep
|
||||
-- from the start — `engagementRetentionPrune` is the pattern, and the rule that
|
||||
-- work learned is that only TERMINAL rows are eligible. The sweep itself lands
|
||||
-- with the runner in Phase 2; the index it needs is here from the beginning.
|
||||
CREATE TABLE IF NOT EXISTS event_run_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
step_id BIGINT NULL,
|
||||
kind VARCHAR(48) NOT NULL,
|
||||
phase VARCHAR(64) NULL,
|
||||
detail JSON NULL,
|
||||
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
-- The run console reads this whole index and nothing else.
|
||||
INDEX idx_evlog_run (run_id, at),
|
||||
-- What the Phase 2 retention sweep queries. Without it the sweep is a table
|
||||
-- scan of every line this deployment has ever logged.
|
||||
INDEX idx_evlog_at (at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What a phase is waiting for, and how far it has got (§E, Phase 5).
|
||||
--
|
||||
-- A phase used to advance on one fact — every step terminal — and that fact
|
||||
-- lives in `event_run_steps`. An advance CONDITION is a second fact, and it is
|
||||
-- not derivable from any row that already exists: `{ on: 'uo.champ.boss_up',
|
||||
-- count: 3 }` is a tally of things that happened between one tick and the next,
|
||||
-- and the runner is not running when they happen. This table is where a firing
|
||||
-- is counted at the moment it fires.
|
||||
--
|
||||
-- **One row per (run, phase), created at phase entry by INSERT IGNORE**, the
|
||||
-- same idempotence `materialisePhase` has and for the same reason: a process
|
||||
-- that died between entering a phase and writing this must not open a second
|
||||
-- gate on the next tick.
|
||||
--
|
||||
-- **The tally is incremented by one statement with the threshold in it**, never
|
||||
-- read-then-written — the argument `event_run_budget`'s conditional increment
|
||||
-- makes, one phase early. Two emits arriving together each add one, and exactly
|
||||
-- one of them crosses `needed`.
|
||||
--
|
||||
-- `last_event` holds ONLY the variables the condition names, not the payload.
|
||||
-- It exists to answer "what did the last one look like, and why did it not
|
||||
-- count", and a copy of a whole game event's data is a second copy of exactly
|
||||
-- the content `engagement_sends` is careful not to keep.
|
||||
--
|
||||
-- `satisfied_by` is a VARCHAR rather than an ENUM for `event_run_log.kind`'s
|
||||
-- reason: the set can grow (an authored timeout was considered and declined for
|
||||
-- Phase 5) and this project has no migration system for a column alter. `kind`
|
||||
-- IS an ENUM, because §E closes it at two shapes.
|
||||
CREATE TABLE IF NOT EXISTS event_run_phase_gates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
kind ENUM('after','on') NOT NULL,
|
||||
-- kind='after': normalised to seconds at save, so the runner never parses a
|
||||
-- duration string. `due_at` is entered_at + this, computed once at entry.
|
||||
after_seconds INT NULL,
|
||||
-- kind='on': the trigger being waited on and the predicate over its declared
|
||||
-- variables. `conditions` is NULL for "any firing of this trigger".
|
||||
trigger_id VARCHAR(96) NULL,
|
||||
conditions JSON NULL,
|
||||
needed INT NOT NULL DEFAULT 1,
|
||||
tally INT NOT NULL DEFAULT 0,
|
||||
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
due_at DATETIME NULL,
|
||||
last_event JSON NULL,
|
||||
last_event_at DATETIME NULL,
|
||||
satisfied_at DATETIME NULL,
|
||||
satisfied_by VARCHAR(16) NULL, -- 'condition' | 'elapsed' | 'forced'
|
||||
forced_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evgate_user FOREIGN KEY (forced_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Entry is INSERT IGNORE against this.
|
||||
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||
-- The emit path's only query: every open gate waiting on this trigger. It runs
|
||||
-- on every game event of every trigger anything waits on, so it is the one
|
||||
-- index in this feature that is on a hot path rather than an admin screen.
|
||||
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Enablement, caps and the verify gate (EVENTS.md §D/§K — Phase 6) ───────
|
||||
|
||||
-- The deployment's switchboard, and the whole of the permission model beyond the
|
||||
-- role (§K layer 2).
|
||||
--
|
||||
-- **Not a grant table.** Nobody is named in it, because the role check already
|
||||
-- answered who; this table answers *what this deployment permits at all*, and
|
||||
-- how much of it per run. That is the distinction §K draws between a capability
|
||||
-- and permission to invoke it: a module declaring `uo.creature.spawn` is code the
|
||||
-- operator installed, not a permission they granted.
|
||||
--
|
||||
-- **A missing row is not "disabled" — it is "the default for its risk class".**
|
||||
-- Rows are written when an admin changes something, never seeded at boot, for a
|
||||
-- reason that is structural rather than tidy: the registry is assembled in
|
||||
-- `registerCore()` and by module `register()`, both of which run under
|
||||
-- `routeManifest.js` and `swagger.js` against a DEAD POOL (MODULE_API.md §2.2).
|
||||
-- A boot-time seed from the registry would be exactly the database write those
|
||||
-- two forbid. Reading a default from the risk class costs one branch and means a
|
||||
-- deployment that never opens this screen behaves correctly.
|
||||
--
|
||||
-- The row survives its action: uninstalling a module leaves the settings behind,
|
||||
-- so re-installing it restores the caps the operator chose rather than silently
|
||||
-- resetting them. The switchboard only lists what is registered *now*, so a
|
||||
-- stranded row is invisible until its action comes back.
|
||||
--
|
||||
-- `action_id` is the primary key rather than an id column: there is exactly one
|
||||
-- row per action and every read is by that id.
|
||||
CREATE TABLE IF NOT EXISTS event_action_settings (
|
||||
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- `{dimension: perRunCap}`. A dimension absent from this object is uncapped by
|
||||
-- this action; an empty object is an action that declares no cost at all.
|
||||
caps JSON NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evset_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What one run has spent, and the most it may.
|
||||
--
|
||||
-- **The cap is COPIED here at run start, not read live.** A run is already
|
||||
-- reproducible in every other respect — it pins a version, and the version is
|
||||
-- immutable — and a cap read live would be the one input to a run's behaviour
|
||||
-- that an admin could change underneath it at three in the morning. Copying also
|
||||
-- makes the console's meter answer the right question afterwards: "what was this
|
||||
-- run allowed", not "what is allowed now".
|
||||
--
|
||||
-- **One row per dimension, so the cap is the tightest of the actions the run's
|
||||
-- version names** (org lead, 2026-09-03). Two actions that both spend
|
||||
-- `uo.creatures` share this row, which is what makes a dimension a bound on the
|
||||
-- run's total effect rather than a per-verb allowance. `effective_from` records
|
||||
-- which action's cap won, so the console can say so.
|
||||
--
|
||||
-- `consumed + ? <= cap` in the WHERE is the whole concurrency story (§E): two
|
||||
-- steps drawing on one dimension in the same tick cannot both see 28/30 and both
|
||||
-- spend, and no transaction is needed to say so. Same shape as the outbox claim
|
||||
-- and the gate's conditional increment, and the same reason.
|
||||
CREATE TABLE IF NOT EXISTS event_run_budget (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
dimension VARCHAR(96) NOT NULL,
|
||||
consumed INT NOT NULL DEFAULT 0,
|
||||
-- **NULL is uncapped, and it is a row rather than an absent one.** A dimension
|
||||
-- every action naming it left uncapped still accumulates here, so the console's
|
||||
-- meter can say "14 spawned, no cap" -- and so that a MISSING row keeps its one
|
||||
-- unambiguous meaning: a step spending a dimension its own run's version never
|
||||
-- priced, which `spend()` refuses.
|
||||
cap INT NULL,
|
||||
-- The action whose cap was the minimum. Documentary: it is what lets the run
|
||||
-- console say "30, from uo.creature.spawn" rather than showing a number the
|
||||
-- operator cannot trace back to a switch they set.
|
||||
effective_from VARCHAR(96) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
-- Seeding is INSERT IGNORE against this, so a tick that overruns into the next
|
||||
-- one cannot double-seed a run's budget.
|
||||
UNIQUE KEY uq_evbud_dim (run_id, dimension)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The cleanup ledger: everything one run created or leased, and what became of
|
||||
-- it (EVENTS.md §D, §L "The ledger's two rules"; Phase 8).
|
||||
--
|
||||
-- **It holds both kinds of thing an event owns.** An OBJECT it created is
|
||||
-- `kind: 'creature'` with `ref` a serial, reverted by its own action's
|
||||
-- `revert()`. A VALUE it leased is `kind: 'override'` with `ref` the lease id and
|
||||
-- `payload` carrying the baseline and what was applied, restored by the lease's
|
||||
-- own `restore()`. One table, because cleanup asks both the same question --
|
||||
-- what is still out there, and did putting it back work.
|
||||
--
|
||||
-- **Rule 1: a resource is recorded BEFORE it is confirmed.** A spawn's serial
|
||||
-- does not exist until the module answers, so what is written before the dispatch
|
||||
-- is a PLACEHOLDER keyed by the step's idempotency key (`kind` = the reserved
|
||||
-- '@step', `ref` = that key). On the answer the reported resources are inserted
|
||||
-- `confirmed` and the placeholder is resolved. If the acknowledgement is lost the
|
||||
-- placeholder survives, and cleanup calls `revert()` with the idempotency key and
|
||||
-- no resources -- which is why §F's `revert({ runId, resources, idempotencyKey })`
|
||||
-- takes the key at all. Recording afterwards instead would make every object
|
||||
-- whose ack was lost invisible to cleanup for ever.
|
||||
--
|
||||
-- **Rule 2: revert is idempotent, and its failure is loud and sticky.** A row
|
||||
-- that never reverts stays visible -- the run reaches `completed` with
|
||||
-- `cleanup_status = 'incomplete'` rather than being held `running`, because a
|
||||
-- tidy `completed` over a shard full of orphaned monsters is the failure that
|
||||
-- would end this feature's credibility on its first bad night.
|
||||
--
|
||||
-- **The unique key is what stops two events leasing one target**, and it must
|
||||
-- hold among LIVE rows only: last week's finished event must not keep this
|
||||
-- week's from leasing the same rate. MariaDB has no partial index, so the
|
||||
-- encoding is a STORED generated column that is NULL once the row is no longer
|
||||
-- ours -- and multiple NULLs do not collide in a unique index. It is derived from
|
||||
-- `status` ALONE and the opaque columns stay in the KEY, which is the shape
|
||||
-- TEAMS.md §2.5 had to be corrected into: MariaDB refuses ON DELETE SET NULL on a
|
||||
-- foreign key whose column is a base column of a stored generated column
|
||||
-- (error 1901), so `step_id` must not appear in the expression.
|
||||
--
|
||||
-- **The key is held by the three statuses that mean "core still believes this is
|
||||
-- ours"** -- `pending`, `confirmed`, `reverting` -- and released by the three that
|
||||
-- mean it is not. §D says "among non-reverted rows", which was written before the
|
||||
-- six statuses had their meanings; taken literally it makes `drifted` and
|
||||
-- `orphaned` hold a target for ever, so one bad night would disable a lease
|
||||
-- permanently with no control able to clear it. `drifted` means somebody else has
|
||||
-- hold of the value and this run has deliberately let go of it; `orphaned` means
|
||||
-- it vanished. Neither is a claim on the target, and both stay LOUD by another
|
||||
-- mechanism -- `cleanup_status = 'incomplete'` and a row on the run console --
|
||||
-- which is what §L's rule 2 actually asks for. Amended 2026-09-03.
|
||||
CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Which step made it. It is how cleanup finds the ACTION to call `revert()` on:
|
||||
-- the row records the module and the opaque names, and the step records the
|
||||
-- verb. SET NULL rather than CASCADE, for `engagement_sends`' reason -- a record
|
||||
-- of what was changed in the world must outlive the row that scheduled it.
|
||||
step_id BIGINT NULL,
|
||||
-- The registering module, copied at record time rather than derived from the
|
||||
-- action id, so an uninstalled module still names itself on the console.
|
||||
owner_module VARCHAR(64) NOT NULL,
|
||||
-- Both module-opaque, stored verbatim, never interpreted -- `ctx.teams.activity.push`'s
|
||||
-- treatment. '@step' is the one reserved `kind` and core owns it.
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
ref VARCHAR(190) NOT NULL,
|
||||
payload JSON NULL,
|
||||
-- A lease's deadline, and NULL for an owned object. It goes DOWN THE WIRE as
|
||||
-- well: the game side restores baseline when it passes, without being asked
|
||||
-- again, which is the fail-safe that makes an unattended world change
|
||||
-- defensible. This column is core's copy of that promise, for the console and
|
||||
-- for the boot-time check.
|
||||
lease_until DATETIME NULL,
|
||||
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
-- Bounded like a step's `attempts`, and for the same reason: a revert that can
|
||||
-- never succeed must become visible rather than cycling for ever. Engagement
|
||||
-- Phase 14's rule -- only a terminal row is ever retention-eligible -- is what
|
||||
-- makes an unbounded counter a row nothing can ever sweep.
|
||||
revert_attempts INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
-- Optional, and module-opaque like the rest: who received it, for a granted
|
||||
-- reward that results should be able to name. `event_run_participants` joins on
|
||||
-- the same key in Phase 10.
|
||||
member_key VARCHAR(190) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
-- 1 while core still believes this resource is this run's, NULL once it is not.
|
||||
-- See the unique key below; derived from `status` alone, deliberately.
|
||||
live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
|
||||
CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
-- "Two events cannot hold a lease on one target", among non-reverted rows.
|
||||
UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
|
||||
-- The run console, and the cleanup sweep's read: one run's ledger in order.
|
||||
INDEX idx_evres_run (run_id, status),
|
||||
-- The cleanup leg's scan across runs, and the boot-time lease self-check.
|
||||
INDEX idx_evres_live (status, lease_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §K's last bound: "a scheduled definition that has never been verified is the
|
||||
-- case worth refusing to start". A version is immutable, so a dry run that passed
|
||||
-- against it stays true — which is what makes the pass a property of the VERSION
|
||||
-- rather than of the definition, and what makes recording it two columns rather
|
||||
-- than a table.
|
||||
--
|
||||
-- Enforced for SCHEDULED starts only (org lead, 2026-09-03): a human pressing
|
||||
-- start is watching, and that human is the review the gate exists to require.
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
|
||||
|
||||
-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
|
||||
--
|
||||
-- **Not a second answer to the question `state` answers**, which is the trap the
|
||||
-- `findSchedulable` comment in eventDefinitions.db.js warns about: `state` says
|
||||
-- whether an event is SCHEDULABLE, and this says whether it is ANNOUNCED. The
|
||||
-- two came apart the moment there was a public surface at all, because
|
||||
-- publishing is what makes a definition runnable -- so without this column a
|
||||
-- surprise invasion would have to be advertised a fortnight in advance in order
|
||||
-- to be allowed to happen.
|
||||
--
|
||||
-- Default 1, so every definition that exists keeps the behaviour it had while
|
||||
-- the only reader was staff, and unlisting is the deliberate act.
|
||||
--
|
||||
-- It hides the definition, its runs and its projections from the public
|
||||
-- surfaces and from a participant's own history. It hides nothing from staff:
|
||||
-- the admin calendar is the operational view, and an event nobody outside can
|
||||
-- see is still an event the team is running.
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS listed TINYINT(1) NOT NULL DEFAULT 1;
|
||||
|
||||
-- ── Integrations: participants, results and the run's announcements
|
||||
-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────
|
||||
|
||||
-- Who took part, and how well. The eleventh and last of §D's core tables.
|
||||
--
|
||||
-- **Core writes this table and never sources it.** A `member_key` is
|
||||
-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of
|
||||
-- Britain" onto a user row and must not try, because the mapping is one game's
|
||||
-- (`shard_links`, for module-uo) and would be compiled into core the moment it
|
||||
-- guessed. A module that knows both halves supplies both — `memberKey` always,
|
||||
-- `userId` when its own link table has one — and core stores what it is told.
|
||||
--
|
||||
-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what
|
||||
-- happened at an event has to survive the deletion of an account that attended
|
||||
-- it, or the results of last year's invasion silently rewrite themselves.
|
||||
--
|
||||
-- **`rank` is NULL until results are published** and is computed then, by
|
||||
-- `core.results.publish`, over `score DESC`. It is a stored column rather than a
|
||||
-- window function in the read because a published result is a fact about a
|
||||
-- moment: a participant added afterwards (a late correction, a module's second
|
||||
-- collect step) must not silently renumber a table people have already read.
|
||||
CREATE TABLE IF NOT EXISTS event_run_participants (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Module-opaque and NOT NULL: it is the identity the module knows, and the
|
||||
-- half of the unique key that makes a repeated collect idempotent. A run whose
|
||||
-- module cannot name its participants has no rows here at all.
|
||||
member_key VARCHAR(190) NOT NULL,
|
||||
user_id INT NULL,
|
||||
-- Signed, because a game may score downward as readily as upward, and DECIMAL
|
||||
-- rather than a float so two equal scores compare equal and a rank is stable.
|
||||
score DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
rank_at INT NULL,
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Module-opaque. Whatever the module wants results to be able to display
|
||||
-- beside a name -- a class, a city, a kill count -- with no core vocabulary in
|
||||
-- it and nothing core ever reads.
|
||||
meta JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- One row per participant per run. What makes a module reporting the same set
|
||||
-- twice -- a retried collect step, a second attempt after a timeout -- an
|
||||
-- upsert rather than a duplicated leaderboard.
|
||||
UNIQUE KEY uq_evpart_member (run_id, member_key),
|
||||
-- The results table: one run, best first.
|
||||
INDEX idx_evpart_score (run_id, score),
|
||||
-- Profile history (`GET /player/events/history`, Phase 14), newest first.
|
||||
INDEX idx_evpart_user (user_id, joined_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- When the results table was published, and by which run of the publish action.
|
||||
--
|
||||
-- A stamp rather than a status: a run either has published results or it has
|
||||
-- not, and the two questions a surface asks -- "may I show this table" and "when
|
||||
-- was it settled" -- are the same column. `core.results.publish` is idempotent
|
||||
-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an
|
||||
-- ordinary retried step.
|
||||
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL;
|
||||
|
||||
-- The run an announce job belongs to, when it belongs to one.
|
||||
--
|
||||
-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are
|
||||
-- not an event's, and nothing about how they are enqueued, retried or rolled up
|
||||
-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job
|
||||
-- for a post that has already been announced -- the common case, since the post
|
||||
-- an event announces is very often the news post that announced it -- without
|
||||
-- either colliding with the first or overwriting `posts.announce_job_id`, which
|
||||
-- is the back-pointer the post admin panel's retry button reads.
|
||||
--
|
||||
-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce
|
||||
-- job that went out is a delivery record and must outlive whatever asked for it,
|
||||
-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a
|
||||
-- constraint here would be the one statement in this file that cannot replay.
|
||||
-- The column is read only to answer "which run announced this", and a run id
|
||||
-- that no longer resolves answers that honestly.
|
||||
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
|
||||
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
|
||||
|
||||
@@ -1,7 +1,483 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.8.0",
|
||||
"moduleApiVersion": "1.10.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
"owner": "core",
|
||||
"label": "Event — a new phase",
|
||||
"description": "An event that is under way has moved on to its next stage.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "phase",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "assault",
|
||||
"description": "The phase key just entered, as authored in the spec."
|
||||
},
|
||||
{
|
||||
"name": "phaseLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The assault",
|
||||
"description": "The phase label, when the spec gave it one. Falls back to the key."
|
||||
},
|
||||
{
|
||||
"name": "phaseIndex",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 2,
|
||||
"description": "Which phase this is, counting from 1."
|
||||
},
|
||||
{
|
||||
"name": "phaseCount",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 4,
|
||||
"description": "How many phases the pinned version has in total."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.cancelled",
|
||||
"owner": "core",
|
||||
"label": "Event — cancelled",
|
||||
"description": "A scheduled event was cancelled by a member of staff.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "reason",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The shard is down for an emergency patch.",
|
||||
"description": "What the staff member gave as the reason, when they gave one."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.completed",
|
||||
"owner": "core",
|
||||
"label": "Event — finished",
|
||||
"description": "An event has finished.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "participantCount",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 47,
|
||||
"description": "How many participants the run recorded. Zero when nothing collected any."
|
||||
},
|
||||
{
|
||||
"name": "durationMinutes",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 95,
|
||||
"description": "How long the run took, start to end, in whole minutes."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.ending",
|
||||
"owner": "core",
|
||||
"label": "Event — winding down",
|
||||
"description": "An event is drawing to a close.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.failed",
|
||||
"owner": "core",
|
||||
"label": "Event — run failed",
|
||||
"description": "An event stopped before it finished.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "admin",
|
||||
"ceiling": "admin",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "phase",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "assault",
|
||||
"description": "The phase it failed in, when it had entered one."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "sidecar responded 503",
|
||||
"description": "The run’s last error, verbatim from the run row."
|
||||
},
|
||||
{
|
||||
"name": "runUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/admin/events/runs/3692",
|
||||
"description": "Site-relative path to the run console."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.scheduled",
|
||||
"owner": "core",
|
||||
"label": "Event — scheduled",
|
||||
"description": "A new event has been added to the calendar.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "startsAt",
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"example": "2026-09-12T20:00:00.000Z",
|
||||
"description": "When the occurrence is due to start, UTC."
|
||||
},
|
||||
{
|
||||
"name": "startsAtLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
|
||||
"description": "The start time written out in the shard-local zone, for a mail to read."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.started",
|
||||
"owner": "core",
|
||||
"label": "Event — starting now",
|
||||
"description": "A scheduled event has begun.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "startsAt",
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"example": "2026-09-12T20:00:00.000Z",
|
||||
"description": "When it actually started, UTC."
|
||||
},
|
||||
{
|
||||
"name": "startsAtLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
|
||||
"description": "The start time written out in the shard-local zone, for a mail to read."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "news.post",
|
||||
"owner": "core",
|
||||
|
||||
@@ -194,6 +194,24 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules",
|
||||
@@ -320,6 +338,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates",
|
||||
@@ -392,6 +419,276 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/publish",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/runs",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/verify",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id/versions",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/actions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/actions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/calendar",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog/options/:sourceId",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/advance",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cancel",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cleanup",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId/log",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"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",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/series",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/series/:seriesId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/series/:seriesId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites",
|
||||
@@ -1970,6 +2267,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/events/history",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams",
|
||||
@@ -2147,6 +2453,30 @@
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/:slug",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/series/:slug",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules",
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/channels"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
@@ -141,6 +149,10 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
@@ -173,6 +185,126 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/publish"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/runs"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/verify"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id/versions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/actions"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/actions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/calendar"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog/options/:sourceId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/advance"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cancel"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cleanup"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/series"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/series/:seriesId"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/series/:seriesId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
@@ -801,6 +933,10 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals/eligible"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/events/history"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams"
|
||||
@@ -873,6 +1009,18 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/series/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
|
||||
@@ -108,7 +108,21 @@ function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
|
||||
// **Line endings are normalised before the comparison**, exactly as
|
||||
// `routeManifest.js` does one file along, and for a reason that is not
|
||||
// cosmetic: this repo is developed on Windows under `core.autocrlf=true`, so
|
||||
// git checks a committed LF blob out as CRLF and a byte comparison then calls
|
||||
// an unchanged manifest stale. That failure is worse than useless — it fires on
|
||||
// every Windows checkout, says "a trigger declaration changed", and is fixed by
|
||||
// regenerating a file whose CONTENT was already correct, which teaches a
|
||||
// developer to ignore the one check that exists to be believed.
|
||||
//
|
||||
// What is being asserted is that the committed manifest describes the same
|
||||
// declarations, and a line ending is not a declaration. Policing the encoding
|
||||
// is `.gitattributes`' job, not this check's.
|
||||
const current = fs.existsSync(MANIFEST_PATH)
|
||||
? fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
|
||||
: ''
|
||||
if (current === next) {
|
||||
process.stdout.write('engagement-triggers.json is current\n')
|
||||
return
|
||||
|
||||
755
server/src/config/coreEventActions.js
Normal file
755
server/src/config/coreEventActions.js
Normal file
@@ -0,0 +1,755 @@
|
||||
// ── Core's own event actions ───────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js
|
||||
// and registered through the same staging area a module will use in Phase 7 —
|
||||
// which is the entire reason these three exist this early. A registry whose first
|
||||
// real registrant is a module is a registry that has already drifted, and §F's
|
||||
// claim that core is "an event engine that can announce, wait, cue a human and
|
||||
// publish results" with NO module installed is only true if core declares the
|
||||
// verbs that do it.
|
||||
//
|
||||
// **Three actions, and between them they cover the three things an event can do
|
||||
// that name no game noun at all**: tell people something, let time pass, and ask
|
||||
// a human to go and do something. A deployment with no game module installed has
|
||||
// a working event system made of exactly these.
|
||||
//
|
||||
// **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease`
|
||||
// names no game noun either — it borrows a value some module declared — but
|
||||
// unlike the other three it genuinely changes the world, so it is `risk: 'change'`
|
||||
// and therefore default-off, admin-only and cap-checked like any module verb.
|
||||
// It is CORE's rather than each module's because §F puts the duration bound and
|
||||
// the two-events-one-target conflict check on core's side of the seam: a lease
|
||||
// verb per module would be that bound re-implemented once per module, advisory
|
||||
// everywhere, and wrong in the first one that forgot it.
|
||||
//
|
||||
// **Phase 10 added the last two, and they are the integrations** (EVENTS.md
|
||||
// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a
|
||||
// post an editor already wrote and queues it through `announce_jobs`, so the
|
||||
// town crier and Discord arrive as already-registered legs with their retry and
|
||||
// their classification rather than as a second delivery pipeline. And
|
||||
// `core.results.publish` is what makes §F's "publish results" literal: it ranks
|
||||
// the run's participants and stamps the table published. Both name a game noun
|
||||
// nowhere, which is why they are core's; six actions is now the whole of what an
|
||||
// event can do on a deployment with no game module installed at all.
|
||||
//
|
||||
// **Phase 2 gave all three real bodies**, and between them they exercise every
|
||||
// shape §F's envelope can take: `core.announce` does work and finishes,
|
||||
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
|
||||
// without finishing at all. The runner learns nothing about any of them by id —
|
||||
// each says what it needs in the envelope, through the same two members Phase 7
|
||||
// hands to a module.
|
||||
//
|
||||
// **This file must not touch the database.** It is required from `registerCore()`,
|
||||
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
|
||||
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
|
||||
// looked up inside `perform()`, per call, which is also what makes a leg
|
||||
// registered by a module that booted later reachable at all. `core.lease` is the
|
||||
// one action here that reaches a table, and it requires the model INSIDE
|
||||
// `perform()` for the same reason — a top-level require would make this file
|
||||
// build a pool during route-manifest generation.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref
|
||||
// from the lease id and the target, so this is the one place a caller can push a
|
||||
// ref past the column — and the ledger's own rule applies: refuse, never
|
||||
// truncate, because a truncated ref is a restore pointed at another object.
|
||||
const MAX_LEASE_REF = 190
|
||||
|
||||
|
||||
/**
|
||||
* Turn the `value` param's text into whatever the named lease says it holds.
|
||||
*
|
||||
* The range check is here too, and it is REQUIRED on the numeric types for the
|
||||
* reason §F gives: unlike a cap, a bad lease value is in force the moment it is
|
||||
* applied, so "0.5 to 5" is not advice.
|
||||
*/
|
||||
function coerceLeaseValue(lease, raw) {
|
||||
const text = String(raw === undefined || raw === null ? '' : raw).trim()
|
||||
if (lease.type === 'string') {
|
||||
// A string lease with a declared value set is bounded here, at authoring
|
||||
// time, exactly as a numeric one is by its range (Phase 12b). Without it the
|
||||
// only check on the value is the game side's, and that refusal arrives
|
||||
// unattended, mid-run, from a step nobody is watching.
|
||||
if (lease.values && !lease.values.includes(text)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`,
|
||||
}
|
||||
}
|
||||
return { ok: true, value: text }
|
||||
}
|
||||
if (lease.type === 'bool') {
|
||||
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
|
||||
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
|
||||
return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` }
|
||||
}
|
||||
const n = Number(text)
|
||||
if (text === '' || !Number.isFinite(n)) {
|
||||
return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` }
|
||||
}
|
||||
if (lease.type === 'int' && !Number.isInteger(n)) {
|
||||
return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` }
|
||||
}
|
||||
if (n < lease.min || n > lease.max) {
|
||||
return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` }
|
||||
}
|
||||
return { ok: true, value: n }
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
id: 'core.announce',
|
||||
label: 'Announce',
|
||||
description:
|
||||
'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.',
|
||||
|
||||
// Nothing in the world changes and nothing is created: a message goes out.
|
||||
// That is what makes the default `on_failure` for this step `retry -> skip`
|
||||
// (§L) rather than `pause`, and it is the honest class even though the
|
||||
// message itself cannot be unsent.
|
||||
risk: 'notify',
|
||||
// A sent announcement is gone. `none` rather than `ledger` is not an
|
||||
// omission — there is no undo to write, and declaring `ledger` would put a
|
||||
// row in the cleanup ledger that teardown could never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
// A leg id, checked against the announce-leg registry at dispatch rather
|
||||
// than here: legs are registered by modules, and this file is evaluated
|
||||
// before any module has registered anything.
|
||||
//
|
||||
// **`source` is what moves that check earlier** (Phase 7). The dispatch
|
||||
// check stays — a module can boot between authoring and the run — but
|
||||
// until now a typo here was caught mid-run and nowhere else, which is the
|
||||
// defect Phase 6's walk hit: an announce leg "site" no module registers,
|
||||
// found by a dry run rather than by the form that accepted it.
|
||||
name: 'leg',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'discord',
|
||||
source: 'core.options.legs',
|
||||
description: 'The announce leg to publish on. Registered legs only.',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'The gates of Britain open at dusk',
|
||||
description: 'Optional heading, for legs that render one.',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'A caravan has been sighted on the road east of Cove.',
|
||||
description: 'The announcement itself. Plain text.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Publish through the announce leg the step names.
|
||||
*
|
||||
* **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
|
||||
* `discord` is core's and `towncrier` is module-uo's, both already registered,
|
||||
* both already carrying a `classify()` that knows what their transport's
|
||||
* failures mean. An event announcement that went out by some other path would
|
||||
* be a second delivery mechanism with its own bugs.
|
||||
*
|
||||
* A leg's `dispatch()` takes a POST — that is the shape the news path gave it
|
||||
* — so an event announcement is presented as one. `excerpt` is the body
|
||||
* because it is the field every leg renders as prose, and `image_url` is null
|
||||
* because an event announcement has no article behind it to illustrate.
|
||||
* Widening the leg contract to carry a second payload shape is a
|
||||
* MODULE_API change, and Phase 7 is where those are made.
|
||||
*
|
||||
* The leg id is checked HERE rather than at authoring time, and that is not
|
||||
* laxness: legs are registered by modules, and a spec is validated in a
|
||||
* process that may have booted before the module that owns the leg.
|
||||
*/
|
||||
async perform({ params, verify }) {
|
||||
const registered = registries.announceLeg(params.leg)
|
||||
if (!registered) {
|
||||
// Terminal, not transient. A leg nobody registers will not appear
|
||||
// between two attempts sixty seconds apart, and the honest cause — a
|
||||
// module removed, or a typo the authoring form could not catch — is a
|
||||
// thing a human fixes.
|
||||
return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
|
||||
}
|
||||
// A dry run reports what it WOULD do and sends nothing (§I). Answering
|
||||
// before the dispatch rather than inside the leg is what keeps that true
|
||||
// for legs written by people who never read this file.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await registered.dispatch({
|
||||
title: params.title || null,
|
||||
excerpt: params.body,
|
||||
image_url: null,
|
||||
})
|
||||
// The leg's own classification, not a second opinion. `retry` vs
|
||||
// `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
|
||||
// already makes, and making it twice is how the two drift.
|
||||
const { outcome, error } = registered.classify(result)
|
||||
if (outcome === 'done') return { ok: true }
|
||||
return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.wait',
|
||||
label: 'Wait',
|
||||
description: 'Let a fixed amount of time pass before the next step of this phase runs.',
|
||||
|
||||
// `inspect` rather than `notify`: nothing is sent and nobody is told. It is
|
||||
// the weakest class the closed set has for an action that is not a broadcast.
|
||||
risk: 'inspect',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'seconds',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 300,
|
||||
description: 'How long to wait. The runner sets the next step due_at from this.',
|
||||
},
|
||||
],
|
||||
|
||||
// A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
|
||||
// NEXT step's `due_at`, which the runner owns, not something this function
|
||||
// sleeps through. A `perform` that slept would hold a step's claim for the
|
||||
// duration and turn a five-minute pause into a five-minute lease — and the
|
||||
// reclaim would then re-dispatch it, so a long enough wait would never end.
|
||||
//
|
||||
// `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
|
||||
// why the runner can honour this without knowing what `core.wait` is.
|
||||
async perform({ params, verify }) {
|
||||
if (verify) return { ok: true }
|
||||
return { ok: true, holdFor: params.seconds }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.cue',
|
||||
label: 'Cue a human',
|
||||
description:
|
||||
'Post an instruction for staff and wait for someone to confirm it was done before the run advances.',
|
||||
|
||||
// The action itself only posts an instruction. Whatever the human then does
|
||||
// is outside this system entirely, which is precisely why the cue exists:
|
||||
// it is how an event uses a capability no module has automated.
|
||||
risk: 'notify',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'instruction',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'Open the north gate and read the herald script in Britain bank.',
|
||||
description: 'What the staff member is being asked to do.',
|
||||
},
|
||||
{
|
||||
name: 'assignee',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'Event Team',
|
||||
description: 'Who the cue is addressed to. A label, not an account.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Post the instruction and PARK. The step does not complete here.
|
||||
*
|
||||
* `await: 'human'` is the envelope member that says so (org lead,
|
||||
* 2026-09-02), and the runner's answer to it is to leave the step `running`
|
||||
* with a NULL lease — genuinely in flight, nothing holding it, so the stale
|
||||
* reclaim passes it by and a cue posted on Friday is still waiting on Monday.
|
||||
* The step ends when someone presses confirm, which is Phase 3's control.
|
||||
*
|
||||
* **Nothing is delivered from here in Phase 2, and that is visible rather
|
||||
* than pretended.** The instruction is carried by the step's own params and
|
||||
* shown on the run console; routing it to Discord or to a staff inbox is
|
||||
* Phase 10's integration work, through the engagement triggers that own every
|
||||
* other notification on this platform. An action that grew its own delivery
|
||||
* path would be the second one.
|
||||
*/
|
||||
async perform({ verify }) {
|
||||
if (verify) return { ok: true }
|
||||
return { ok: true, await: 'human' }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.lease',
|
||||
label: 'Borrow a value',
|
||||
description:
|
||||
'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.',
|
||||
|
||||
// The world changes and it changes back, so `change` rather than
|
||||
// `irreversible` — and `change`'s default `on_failure` is `pause`, which is
|
||||
// the right stop for a run that failed halfway through altering the world.
|
||||
risk: 'change',
|
||||
// The one action core ships in this class. `override` is what tells the
|
||||
// cleanup sweep to restore through the LEASE registry rather than through an
|
||||
// action's `revert()`, which is why this action needs no `revert()` of its own
|
||||
// and why the registry refuses one on it.
|
||||
reversible: 'override',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'lease',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'uo.rate.skillgain',
|
||||
source: 'core.options.leases',
|
||||
description: 'Which declared value to borrow.',
|
||||
},
|
||||
{
|
||||
// **A string, and the coercion is here rather than in the type system.**
|
||||
// A param declares ONE type; a lease declares its own, and they are four
|
||||
// different ones. Typing this `float` would make a boolean lease
|
||||
// unauthorable and a string lease nonsense, so the field takes text and
|
||||
// this action turns it into whatever the named lease said it holds — the
|
||||
// one place that knows both halves.
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: '3.0',
|
||||
description: 'What to hold it at, in whatever type the lease declares.',
|
||||
},
|
||||
{
|
||||
// **Optional here, required by the LEASE** (Phase 12b), and the two are
|
||||
// not the same statement. A param's `required` is a property of the
|
||||
// action, and this action serves both a config key (which has no target)
|
||||
// and an object property (which cannot be named without one) — so the
|
||||
// field is declared optional and `perform` refuses a targeted lease with
|
||||
// nothing in it, in the lease's own words.
|
||||
//
|
||||
// It carries no `source` for the same reason: the values behind it are
|
||||
// the chosen LEASE's, and a param declares one source for all time. The
|
||||
// lease's own `target.source` is what the authoring form reads once the
|
||||
// author has picked a lease, which is the only moment the right list is
|
||||
// knowable.
|
||||
name: 'target',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: '003f11b8-9bfa-4587-991e-ca263004efe6',
|
||||
description: 'Which one, for a value that exists on many things. Leave empty otherwise.',
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 120,
|
||||
description: 'How long to hold it. Core refuses more than the lease allows.',
|
||||
},
|
||||
],
|
||||
|
||||
// What a lease costs is the LEASE's business to bound, not a budget's:
|
||||
// `maxDurationMs` and the numeric range are declared beside the callables and
|
||||
// enforced below. A cap dimension here would be core inventing an accounting
|
||||
// unit for something a module already bounds — and `registerEventBudgets`
|
||||
// refuses a dimension nobody declared, which is exactly the rule that would
|
||||
// then bite core's own action.
|
||||
|
||||
/**
|
||||
* Read the baseline, reserve the target, apply the value.
|
||||
*
|
||||
* **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target
|
||||
* is knowable before the dispatch — it is the lease id the step names — so
|
||||
* the ledger row is written with its real `kind` and `ref` BEFORE anything
|
||||
* touches the world, and the two-events-one-target refusal comes from the
|
||||
* unique index at that moment rather than from a check that read and then
|
||||
* wrote. A second run asking for a lease another run holds comes back
|
||||
* `refused`, in the same words a cap breach uses and for the same reason:
|
||||
* nothing is broken, the deployment already has that value spoken for.
|
||||
*
|
||||
* The order is read then reserve then apply, and a failure at each stage
|
||||
* undoes the one before it: a reservation whose `apply` refuses is released
|
||||
* here rather than left for the sweep, because there is nothing out there to
|
||||
* give back and a shard that is merely down must not lock a lease out for the
|
||||
* length of a retry cycle.
|
||||
*/
|
||||
async perform({ runId, stepId, params, verify }) {
|
||||
// eslint-disable-next-line global-require
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const lease = registries.eventLease(params.lease)
|
||||
if (!lease) {
|
||||
return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` }
|
||||
}
|
||||
|
||||
const coerced = coerceLeaseValue(lease, params.value)
|
||||
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
|
||||
|
||||
// **The target is checked before anything else about the world is read**
|
||||
// (Phase 12b), because both of its failures are authoring mistakes rather
|
||||
// than outages: a targeted lease with no target names nothing, and a target
|
||||
// on a lease that has none is an author who has confused two fields. Both
|
||||
// are `retry: false` — the second attempt has the same params.
|
||||
const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim()
|
||||
if (lease.target && !targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` }
|
||||
}
|
||||
if (!lease.target && targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` }
|
||||
}
|
||||
const target = lease.target ? targetRaw : null
|
||||
const ref = registries.leaseRef(lease.id, target)
|
||||
// Refused rather than truncated, on the ledger's own rule for a resource
|
||||
// ref: a truncated ref is a restore pointed at the wrong object.
|
||||
if (ref.length > MAX_LEASE_REF) {
|
||||
return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` }
|
||||
}
|
||||
|
||||
const minutes = Number(params.minutes)
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
|
||||
}
|
||||
const ms = Math.round(minutes * 60_000)
|
||||
if (ms > lease.maxDurationMs) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`,
|
||||
}
|
||||
}
|
||||
|
||||
// **The dry run stops here, and it has still checked everything worth
|
||||
// checking**: the lease exists, the value is in range and the duration is
|
||||
// allowed. What it deliberately does not do is reserve the target — a
|
||||
// verify that took a lease would be a dry run that changed something, and
|
||||
// it would then refuse the real run that followed it.
|
||||
//
|
||||
// It also does not check that the TARGET exists, and that is the same
|
||||
// rule rather than an exception: asking the game side whether a spawner is
|
||||
// there is a live read the shard may be down for, and a dry run that fails
|
||||
// because a shard is restarting would make `verified_at` a property of the
|
||||
// moment rather than of the version (§K).
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const baseline = await lease.read({ target })
|
||||
if (!baseline || baseline.ok !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
error: baseline && baseline.error
|
||||
? String(baseline.error)
|
||||
: `could not read the current value of ${lease.label}`,
|
||||
}
|
||||
}
|
||||
|
||||
const until = new Date(Date.now() + ms)
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId,
|
||||
stepId,
|
||||
owner: lease.owner || 'core',
|
||||
kind: 'override',
|
||||
// **The ref carries the target, and that is what makes the unique index
|
||||
// right rather than merely present.** Reserved under the lease id alone,
|
||||
// an event turning up one spawner would lock every other run out of every
|
||||
// other spawner — a conflict check that refuses correct work is as wrong
|
||||
// as one that permits a collision, and on a shard with 6,707 spawners it
|
||||
// is the failure an operator would actually meet.
|
||||
ref,
|
||||
payload: {
|
||||
target: ref,
|
||||
leaseTarget: target,
|
||||
baseline: baseline.value,
|
||||
applied: coerced.value,
|
||||
until: until.toISOString(),
|
||||
},
|
||||
leaseUntil: until,
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : ''
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${lease.label} is already leased by another run${heldBy}`,
|
||||
}
|
||||
}
|
||||
|
||||
// **`until` goes down the wire** (§F). The module passes it to its sidecar
|
||||
// and the game side restores baseline when it passes, without being asked
|
||||
// again — the fail-safe that makes an unattended, scheduled world change
|
||||
// defensible, because the worst case is a world back at baseline early
|
||||
// rather than one stuck changed indefinitely.
|
||||
let applied
|
||||
try {
|
||||
applied = await lease.apply(coerced.value, until, { target })
|
||||
} catch (err) {
|
||||
applied = { ok: false, error: err.message }
|
||||
}
|
||||
if (!applied || applied.ok !== true) {
|
||||
await resourcesDb.markReverted(reserved.id)
|
||||
return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` }
|
||||
}
|
||||
|
||||
await resourcesDb.confirm(reserved.id)
|
||||
// **The run now owes the world something, and something has to say so.**
|
||||
// The generic path marks a run dirty when it records a module's reported
|
||||
// resources; this action reserves its own row and never goes through it, so
|
||||
// a run whose only resource was a lease would have kept `cleanup_status =
|
||||
// 'not_required'` and never been swept. Found by the live walk, and the
|
||||
// cleanup leg's own scan was widened to make the class impossible rather
|
||||
// than only this instance.
|
||||
// eslint-disable-next-line global-require
|
||||
await require('../events/ledger').markRunDirty(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/**
|
||||
* Which of this run's leases the game side still has a record of (Phase 11b).
|
||||
*
|
||||
* **A lease row had no reconcile path at all until this existed**, and nothing
|
||||
* failed to say so. `cleanup.js` resolves a resource to the action of the step
|
||||
* that made it, and for a lease that action is `core.lease` — a CORE action, on
|
||||
* a path a module cannot register anything on. So every `override` row came
|
||||
* back `unanswered` for the life of the run, and a lease the shard had quietly
|
||||
* dropped (a restart reverts every config lease, by design) stayed in the
|
||||
* ledger as live until teardown went looking for a baseline nobody was holding.
|
||||
*
|
||||
* The question asked is deliberately NOT "is the value still what we applied".
|
||||
* That is drift, and drift is teardown's verdict to deliver through `restore`
|
||||
* so the row lands as `drifted` with the current value beside it. A reconcile
|
||||
* that inferred absence from a changed value would orphan the row first and
|
||||
* throw that away — the operator would be told the lease vanished rather than
|
||||
* that somebody moved it.
|
||||
*
|
||||
* A lease with no `inForce()` is reported in force, which is core's posture
|
||||
* everywhere else in this file: "I could not ask" must never be recorded as
|
||||
* "it is gone".
|
||||
*/
|
||||
async reconcile({ resources }) {
|
||||
const inForce = []
|
||||
|
||||
for (const row of resources || []) {
|
||||
if (row.kind !== 'override') continue
|
||||
|
||||
// Resolved through the ref parser rather than by a bare map lookup: a
|
||||
// targeted row's ref is `<id>#<target>` and `eventLease` would miss it,
|
||||
// which would silently report every property lease still in force.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
|
||||
if (!lease || typeof lease.inForce !== 'function') {
|
||||
inForce.push(row.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null })
|
||||
} catch (err) {
|
||||
answer = null
|
||||
}
|
||||
|
||||
// Only an explicit `held: false` takes a row out. A module that threw, timed
|
||||
// out, or answered something unrecognisable has not said the lease is gone.
|
||||
if (answer && answer.ok === true && answer.held === false) continue
|
||||
|
||||
inForce.push(row.ref)
|
||||
}
|
||||
|
||||
return { ok: true, inForce }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.announce.post',
|
||||
label: 'Announce a post',
|
||||
description:
|
||||
'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.',
|
||||
|
||||
// Nothing in the world changes and nothing is created; a message goes out.
|
||||
// Same class as `core.announce` and for the same reason.
|
||||
risk: 'notify',
|
||||
// The job is queued, the legs deliver, and none of it can be unsent. A
|
||||
// `ledger` here would put a row in the cleanup ledger that teardown could
|
||||
// never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// **`core.announce` sends a line; this sends an ARTICLE**, and that is the
|
||||
// whole difference between them (EVENTS.md §J, "News"). Events does not
|
||||
// write posts — `ctx.posts` is read-only to modules and the CMS is core's —
|
||||
// so an event that wants prose, an image and a permanent page links a post
|
||||
// an editor already wrote. What this action adds over `core.announce` is
|
||||
// therefore not a second transport but a second SHAPE: every leg's
|
||||
// `dispatch()` takes a post, and this is the one that hands it a real one.
|
||||
params: [
|
||||
{
|
||||
name: 'postId',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 412,
|
||||
source: 'core.options.posts',
|
||||
description: 'The published post to announce. Any category.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Queue the post on every registered leg, as this run's announcement.
|
||||
*
|
||||
* **The refusals are all `retry: false`**, and each is a thing a human has to
|
||||
* fix: a post id that names nothing, or a draft. Neither will have changed
|
||||
* sixty seconds later, and retrying would spend two more attempts before
|
||||
* saying the same thing.
|
||||
*
|
||||
* **What it does NOT wait for is delivery.** `enqueueForRun` writes the job
|
||||
* and the legs and returns; `announceWorker` drains them on its own tick with
|
||||
* its own backoff. So this step is `done` when the announcement is queued,
|
||||
* not when Discord has it — which is honest, because a leg that fails after
|
||||
* six attempts over two hours is not something a step could usefully have
|
||||
* stayed open for, and the post admin panel is where that failure is already
|
||||
* surfaced.
|
||||
*/
|
||||
async perform({ runId, params, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const postId = Number(params.postId)
|
||||
if (!Number.isInteger(postId) || postId < 1) {
|
||||
return { ok: false, retry: false, error: `"${params.postId}" is not a post id` }
|
||||
}
|
||||
|
||||
const post = await posts.getById(postId)
|
||||
if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` }
|
||||
if (!post.published) {
|
||||
// A draft has no public page for a town-crier line to point at, and
|
||||
// announcing one would publish its title to a shard before an editor
|
||||
// meant to. Refused rather than published on the author's behalf:
|
||||
// publishing is the CMS's decision and this action is not it.
|
||||
return { ok: false, retry: false, error: `"${post.title}" is not published` }
|
||||
}
|
||||
|
||||
// The dry run has now checked everything worth checking — the post exists
|
||||
// and is published — and queues nothing. Checked BEFORE the legs are read,
|
||||
// because a deployment with no leg registered is a real state and a verify
|
||||
// that reported it as a failure would refuse a plan that is fine.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await announceJobs.enqueueForRun(postId, runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.results.publish',
|
||||
label: 'Publish the results',
|
||||
description:
|
||||
'Rank this run\'s participants by score and publish the results table.',
|
||||
|
||||
// Nothing in the game world changes and nobody is messaged: a table core
|
||||
// already holds becomes readable. `inspect` is the weakest class the closed
|
||||
// set has and it is the honest one — which also means this action is
|
||||
// default-ON like `core.wait`, and an author can place it without an admin
|
||||
// first visiting the switchboard.
|
||||
risk: 'inspect',
|
||||
// **`none`, and it is worth saying why a publication is not reversible.**
|
||||
// Nothing is created that core would have to come back for; un-publishing is
|
||||
// an admin decision about a table, not a teardown obligation, and a `ledger`
|
||||
// row here would make every completed event carry an outstanding resource
|
||||
// for ever.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// No params. What is published is this run's participants, which is the only
|
||||
// set there is — a param naming which run would be a way to publish someone
|
||||
// else's results from inside your own event.
|
||||
params: [],
|
||||
|
||||
/**
|
||||
* Rank, stamp, and say how many.
|
||||
*
|
||||
* **Idempotent by construction**, which is what makes it safe as an ordinary
|
||||
* retried step: ranking is a total order over `(score, joined_at, id)`, so
|
||||
* running it twice over an unchanged table writes the same numbers, and the
|
||||
* stamp simply moves. A late participant added by a second collect step and
|
||||
* a re-publish afterwards renumbers deliberately — that is the operator
|
||||
* asking for exactly that.
|
||||
*
|
||||
* **A run with no participants publishes an empty table rather than
|
||||
* failing.** "Nobody was recorded" is a true and renderable result, and it is
|
||||
* the state of every run until a module can source attendance at all (Phase
|
||||
* 12). Failing here would make an event whose module reports nothing look
|
||||
* broken on the console for a reason that has nothing to do with the event.
|
||||
*/
|
||||
async perform({ runId, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await participantsDb.rankRun(runId)
|
||||
await runsDb.markResultsPublished(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Core's own param option sources (§F, Phase 7) ──────────────────
|
||||
//
|
||||
// One, and it is core's half of the seam it hands a module on the same boot: a
|
||||
// param's `source` names a registered option source, core asks it for values, and
|
||||
// the authoring form renders a dropdown instead of a text box.
|
||||
//
|
||||
// **The legs are already a registry with labels in it**, so this costs nothing
|
||||
// new — which is what makes it the right first exercise. `resolve()` is called
|
||||
// per request rather than read once, for the same reason `core.announce` looks a
|
||||
// leg up inside `perform()`: a leg registered by a module that booted after this
|
||||
// file was evaluated must still appear, and a module uninstalled since must stop
|
||||
// appearing.
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'core.options.legs',
|
||||
label: 'Announce legs',
|
||||
description: 'Every delivery leg registered on this deployment right now.',
|
||||
async resolve() {
|
||||
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.options.leases',
|
||||
label: 'Borrowable values',
|
||||
description: 'Every value a module has declared this deployment may lease.',
|
||||
async resolve() {
|
||||
return registries
|
||||
.allEventLeases()
|
||||
.map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.options.posts',
|
||||
label: 'Published posts',
|
||||
description: 'Every published post an event may announce, newest first.',
|
||||
/**
|
||||
* **The one option source in core that reaches a table**, and the reason it
|
||||
* is allowed to is the rule §F draws about WHEN: a source resolves on its own
|
||||
* request (`GET /admin/events/catalog/options/:sourceId`), which is a live
|
||||
* request on a booted server, not at `register()` time under a dead pool.
|
||||
*
|
||||
* Grouped by category so the dropdown separates news from the newsletter
|
||||
* rather than presenting one long list in which the two are indistinguishable
|
||||
* — a `group` is what the form renders as an optgroup, and it costs a column
|
||||
* that is already selected.
|
||||
*/
|
||||
async resolve() {
|
||||
// eslint-disable-next-line global-require
|
||||
const postsDb = require('../model/posts/posts.db')
|
||||
const rows = await postsDb.listPublishedForOptions(200)
|
||||
return rows.map((p) => ({ value: p.id, label: p.title, group: p.category }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { ACTIONS, OPTION_SOURCES }
|
||||
@@ -75,6 +75,38 @@ const STREAMS = [
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **One of the seven `event.` triggers is also a stream, and that is a
|
||||
// decision rather than an oversight** (org lead, 2026-09-04). A stream is a
|
||||
// PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only
|
||||
// for ids registered here, `publishToUsers` joins `notification_subscriptions`,
|
||||
// and that table is only ever written for a channel a user could switch on. So
|
||||
// a trigger that is not also a stream can be mailed and put in the inbox, and
|
||||
// its push is dead — a tickle published to nobody, which the send log
|
||||
// nonetheless records as sent. Found on the live rig; the seeded rule named
|
||||
// `push` before this line existed.
|
||||
//
|
||||
// **`run.started` alone, because push is the channel that says "now".** It is
|
||||
// the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's
|
||||
// *"come back for X"* — and the other six are things a player reads when they
|
||||
// next look. Six more toggles would put a wall of switches on the preferences
|
||||
// screen for one feature, and `event.phase.changed` is the one most likely to
|
||||
// buzz a phone four times in an evening.
|
||||
//
|
||||
// Same id as the trigger, which is §7.2's one namespace and the same-owner
|
||||
// upgrade `news.post` already is: one id, one owner, two facets.
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Events — starting now',
|
||||
description: 'A scheduled event is beginning.',
|
||||
// Not owner-keyed: this is a public event happening in public, not a fact
|
||||
// about one account's own property. Same as `news.post`.
|
||||
personal: false,
|
||||
// A player with no linked game account can still want to know an event is on.
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
|
||||
@@ -29,6 +29,29 @@
|
||||
// test-send without a live game event, which is the reason template systems go
|
||||
// untested.
|
||||
|
||||
/**
|
||||
* The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
|
||||
* once because they are spread into all seven.
|
||||
*
|
||||
* `baseFor()` has always computed them and nothing declared them, so
|
||||
* `engagementEmit.validatePayload` dropped all three before a template could see
|
||||
* one — they were absent from the variable list an author picks from, and every
|
||||
* single event emit logged `emit carried undeclared variables`. Found by the
|
||||
* Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
|
||||
*
|
||||
* All three are optional, and each for its own reason rather than by default: an
|
||||
* event need not carry a summary, most events belong to no series, and a run
|
||||
* whose definition has been deleted resolves no zone.
|
||||
*/
|
||||
const EVENT_AMBIENT = [
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
|
||||
description: 'The event’s one-line summary, when it has one.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
|
||||
description: 'The arc this event belongs to, when it belongs to one.' },
|
||||
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
|
||||
description: 'The zone the run was computed in — what a time in the body should be read as.' },
|
||||
]
|
||||
|
||||
const TRIGGERS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
@@ -149,6 +172,269 @@ const TRIGGERS = [
|
||||
description: 'Site-relative path to the announcement.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **Seven triggers, one per moment a run passes through that somebody outside
|
||||
// the run console might want to hear about — and Events owns none of the
|
||||
// delivery.** A run emits; an operator's rule decides who is told, on what,
|
||||
// and how often. That is the whole of §J's "clean fit" row, and it is why
|
||||
// there is no announcement machinery anywhere in `utils/eventRunner.js`
|
||||
// beyond a call to `emit`.
|
||||
//
|
||||
// **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org
|
||||
// lead 2026-09-04). `run.failed` is an operational fact — a step ran out of
|
||||
// attempts, the world may be half-changed — and a rule that mailed it to
|
||||
// every subscriber would publish the deployment's incidents. The other six
|
||||
// describe a public event happening in public, so they sit exactly where
|
||||
// `news.post` sits: ceiling `authenticated`, default audience `subscribers`,
|
||||
// which is "people who asked to be told" rather than the whole user table.
|
||||
//
|
||||
// **Every `description` here is read by two audiences**, and the second one is
|
||||
// easy to forget: the rule editor's catalog, and — through
|
||||
// `projection.project`'s `intro` fallback — every recipient of an unauthored
|
||||
// render through `notify.event` or `inapp.event`. So each is prose a player
|
||||
// can read rather than a note to the operator. The live rig caught the
|
||||
// original `run.failed` line, which ended "Staff-facing." and put those words
|
||||
// in an administrator's own inbox item. Who a trigger is for is said by its
|
||||
// CEILING, which is the only place that can enforce it anyway.
|
||||
//
|
||||
// **`subjectKey: 'runId'` on every one of them**, and it is the one place
|
||||
// these differ from `news.post`. A cooldown keyed on the user would make
|
||||
// `phase.changed` mean "at most one phase of at most one event an hour",
|
||||
// silently swallowing the second wave of an invasion because the first wave's
|
||||
// mail went out forty minutes ago. Keyed on the run it means "at most one
|
||||
// line an hour ABOUT THIS RUN", which is the useful sentence — and across
|
||||
// runs of the same definition the ids differ, so a weekly event is not
|
||||
// throttled by last week's.
|
||||
//
|
||||
// **All six public ones now declare `eventUrl`, and Phase 14a is what made
|
||||
// that legal.** Until it there was no public event page at all — `App.jsx`
|
||||
// mounted nothing under `/site/events` — and `news.post` had already paid for
|
||||
// that mistake once: its `postUrl` example named `/news/<slug>`, a path that
|
||||
// did not exist, so the template editor previewed a link that was dead in
|
||||
// every mail it sent. The variable arrived with the page it points at, which
|
||||
// is what makes this a version bump (1 -> 2) rather than a correction.
|
||||
//
|
||||
// **It carries `?run=`, and the query string is the whole reason it is a run
|
||||
// url and not an event url.** The page lives at the DEFINITION's slug, so a
|
||||
// weekly event has one stable address — but every one of these triggers is
|
||||
// about one OCCURRENCE, and a mail about last Friday's invasion whose link
|
||||
// opened next Friday's would answer a different question from the one the
|
||||
// reader clicked. `run.failed` keeps its own `runUrl` into the admin console
|
||||
// and gains nothing here: an admin reading about broken machinery wants the
|
||||
// console, not the storyline.
|
||||
{
|
||||
id: 'event.run.scheduled',
|
||||
label: 'Event — scheduled',
|
||||
description: 'A new event has been added to the calendar.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When the occurrence is due to start, UTC.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Event — starting now',
|
||||
description: 'A scheduled event has begun.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When it actually started, UTC.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.phase.changed',
|
||||
label: 'Event — a new phase',
|
||||
description: 'An event that is under way has moved on to its next stage.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'phase', type: 'string', required: true, example: 'assault',
|
||||
description: 'The phase key just entered, as authored in the spec.' },
|
||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
|
||||
description: 'The phase label, when the spec gave it one. Falls back to the key.' },
|
||||
{ name: 'phaseIndex', type: 'int', required: true, example: 2,
|
||||
description: 'Which phase this is, counting from 1.' },
|
||||
{ name: 'phaseCount', type: 'int', required: true, example: 4,
|
||||
description: 'How many phases the pinned version has in total.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.ending',
|
||||
label: 'Event — winding down',
|
||||
description: 'An event is drawing to a close.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.completed',
|
||||
label: 'Event — finished',
|
||||
description: 'An event has finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// Counted from `event_run_participants` at emit. Zero on a run whose
|
||||
// module reported nobody, which is every run until a module collects —
|
||||
// a template that says "47 took part" needs a number that is never
|
||||
// missing, and "0" is the honest one.
|
||||
{ name: 'participantCount', type: 'int', required: true, example: 47,
|
||||
description: 'How many participants the run recorded. Zero when nothing collected any.' },
|
||||
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
|
||||
description: 'How long the run took, start to end, in whole minutes.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.cancelled',
|
||||
label: 'Event — cancelled',
|
||||
description: 'A scheduled event was cancelled by a member of staff.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// **The operator's reason, and not the run's `last_error`.** `cancel`
|
||||
// takes a `{ reason }` a human typed for other humans; a diagnostic
|
||||
// string is for the run console and would read as gibberish in a mail.
|
||||
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
|
||||
description: 'What the staff member gave as the reason, when they gave one.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.failed',
|
||||
label: 'Event — run failed',
|
||||
description: 'An event stopped before it finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
// **`admin`, and both halves of that.** The ceiling is the security
|
||||
// boundary (§J, G24): no rule may ever widen this past admins, because a
|
||||
// failure names the deployment's own broken machinery. The default audience
|
||||
// matches, so a rule created from this trigger starts where it must end.
|
||||
audience: 'admin',
|
||||
ceiling: 'admin',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'phase', type: 'string', required: false, example: 'assault',
|
||||
description: 'The phase it failed in, when it had entered one.' },
|
||||
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
|
||||
description: 'The run’s last error, verbatim from the run row.' },
|
||||
// The admin console, not the public page — and this trigger gains no
|
||||
// `eventUrl` at all. An admin reading that the machinery broke wants the
|
||||
// steps and the errors, not the storyline. See the note above the six.
|
||||
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
|
||||
description: 'Site-relative path to the run console.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TRIGGERS }
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
//
|
||||
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
|
||||
// would be a v2 mount); `server` is the informational package version.
|
||||
//
|
||||
// ── `capabilities` (Phase 14a) ──
|
||||
//
|
||||
// Opaque strings naming what CORE serves beyond the surface every backend has —
|
||||
// the same idea as a module's `capabilities` on GET /public/modules, and
|
||||
// deliberately the same word, so a client feature-detects one way rather than
|
||||
// two. They are a different LIST because core is not a module: publishing core
|
||||
// as a pseudo-module would leave a client unable to tell "this backend has
|
||||
// events" from "a module called core happens to be installed", which is exactly
|
||||
// the distinction the loader exists to make.
|
||||
//
|
||||
// The value is in what is ABSENT. A backend released before Events answers this
|
||||
// object with no `capabilities` key at all, so a client can tell an older site
|
||||
// from one that simply has nothing on its calendar — which it could not do by
|
||||
// probing /public/events, where "not built" and "temporarily down" look alike.
|
||||
//
|
||||
// Static, because these are compiled-in features rather than installed ones:
|
||||
// a core that has these routes always has them. An unknown string is to be
|
||||
// treated as absent, exactly as MODULE_API.md §2.1 says of a module's.
|
||||
|
||||
const pkg = require('../../package.json')
|
||||
|
||||
@@ -18,4 +37,6 @@ module.exports = {
|
||||
service: 'runic-gateway', // stable backend identifier for first-run detection
|
||||
api: 'v1', // API contract version (matches the /api/v1 mount)
|
||||
server: pkg.version || '0.0.0', // server package version (informational)
|
||||
// What core serves beyond the baseline. See the note above.
|
||||
capabilities: ['events'],
|
||||
}
|
||||
|
||||
@@ -156,11 +156,27 @@ async function resolveForRule(rule, event) {
|
||||
* same id more tightly. That is precisely the case where a stale rule would
|
||||
* otherwise mail a population the current declaration forbids, which is what
|
||||
* makes this the security boundary rather than a duplicate check.
|
||||
*
|
||||
* **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may
|
||||
* carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the
|
||||
* effective bound is the MEET of the two, so a firing can only ever narrow what
|
||||
* the declaration allows. Two incomparable ceilings meet to null and the gate
|
||||
* refuses: `owner` and `staff` have no common descendant, and picking one would
|
||||
* be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value
|
||||
* cannot get here — `emit` validates it against the same lattice — but the null
|
||||
* is handled anyway, because this is the boundary and a boundary that trusts its
|
||||
* caller is not one.
|
||||
*
|
||||
* @param {string} triggerId
|
||||
* @param {string} ceiling the audience the rule resolved to
|
||||
* @param {string|null} [emitted] a narrowing ceiling this firing carries
|
||||
*/
|
||||
function permitted(triggerId, ceiling) {
|
||||
function permitted(triggerId, ceiling, emitted = null) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) return false
|
||||
return ceilings.permits(declaration.ceiling, ceiling)
|
||||
const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling
|
||||
if (!bound) return false
|
||||
return ceilings.permits(bound, ceiling)
|
||||
}
|
||||
|
||||
module.exports = { resolveForRule, permitted, defaultOnChannels }
|
||||
|
||||
@@ -248,4 +248,18 @@ const vocabulary = () =>
|
||||
/** Convenience for a caller holding only a trigger id. */
|
||||
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
|
||||
|
||||
module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }
|
||||
// `checkLiteral` is exported for the event system's step-param validator
|
||||
// (EVENTS.md §F, Phase 1), which checks an authored param value against an
|
||||
// action's declared param type — the same six types over the same coercion. A
|
||||
// second copy of this switch would be a second answer to "is this a datetime",
|
||||
// and the two would drift on the first zone-suffixed string somebody typed.
|
||||
module.exports = {
|
||||
validate,
|
||||
validateFor,
|
||||
evaluate,
|
||||
vocabulary,
|
||||
checkLiteral,
|
||||
OPERATORS,
|
||||
MAX_LIST,
|
||||
MAX_DEPTH,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ── The five rules core ships, all of them OFF ─────────────────────────────
|
||||
// ── The seven rules core ships, all of them OFF ────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
|
||||
// people with no operator configuration at all: the code decided who was mailed
|
||||
@@ -34,6 +34,11 @@
|
||||
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
|
||||
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
|
||||
// existing group is a rule that only fresh installs will ever see.
|
||||
//
|
||||
// **EVENTS.md Phase 10 added two more, and a third key**, for the event
|
||||
// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third
|
||||
// application — a deployment that has already stamped the news key must still
|
||||
// see these.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
@@ -46,6 +51,9 @@ const SEEDED_KEY = 'engagement_team_rules_seeded'
|
||||
// Phase 11's, and separate for the reason above. Same shape, same semantics.
|
||||
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
|
||||
|
||||
// EVENTS.md Phase 10's, third group, third key.
|
||||
const EVENT_SEEDED_KEY = 'engagement_event_rules_seeded'
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
trigger_id: 'team.forum.post',
|
||||
@@ -145,6 +153,73 @@ const NEWS_RULES = [
|
||||
},
|
||||
]
|
||||
|
||||
// Phase 10's two, in their own list under their own one-shot key — the rule
|
||||
// Phase 11 established, applied for the second time. Appending to `NEWS_RULES`
|
||||
// would seed these on fresh installs only and on exactly the upgrades that want
|
||||
// them, never.
|
||||
//
|
||||
// **Two rules for seven triggers, and that is the whole decision** (org lead,
|
||||
// 2026-09-04). Every one of the seven is declared, so an operator can write a
|
||||
// rule against any of them from the rules screen; what is SEEDED is the pair
|
||||
// somebody would otherwise have to build from scratch on the first day — the
|
||||
// player-facing "it is starting" and the staff-facing "it broke". Seeding all
|
||||
// seven would grow Admin → Engagement → Rules by seven disabled rows nobody
|
||||
// asked for, and `event.phase.changed` is the one most likely to be switched on
|
||||
// by accident and then mail a player four times in one evening.
|
||||
const EVENT_RULES = [
|
||||
{
|
||||
trigger_id: 'event.run.started',
|
||||
name: 'Events — starting now',
|
||||
// `subscribers`, the trigger's own default: people who opted into this id on
|
||||
// at least one channel. Not `authenticated`, even though the ceiling permits
|
||||
// it — an event is worth telling people who asked to be told about events,
|
||||
// and mailing the whole user table every Saturday night is how a feature
|
||||
// earns a spam complaint. An operator who wants the whole site can widen it;
|
||||
// the ceiling is what stops them widening it past that.
|
||||
audience: 'subscribers',
|
||||
// All three, like the news rule and for the same reason: push is the channel
|
||||
// that gets somebody to log in *now*, which is the entire point of a
|
||||
// "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is
|
||||
// the surface a content-free tickle deep-links into.
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
// The one bespoke body this phase seeds; see `templateSeeds.js` for why it
|
||||
// is one and not seven. `inapp.event` is the in-app renderer's generic, and
|
||||
// push carries no content by construction and needs no template.
|
||||
template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' },
|
||||
// An hour, per user PER RUN — `event.run.started` declares `subjectKey:
|
||||
// 'runId'`, so the cooldown subject is the run and not the recipient. It is
|
||||
// near-redundant on a trigger that fires once per run, which is the point:
|
||||
// it costs nothing and it is the guard if a run is ever restarted.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'event.run.failed',
|
||||
name: 'Events — a run failed',
|
||||
// `admin`, which is both the trigger's default and its ceiling. A failed run
|
||||
// names the deployment's own broken machinery — a sidecar that did not
|
||||
// answer, a step that ran out of attempts — and there is no widening of this
|
||||
// that is not a disclosure.
|
||||
audience: 'admin',
|
||||
// No push. An admin's phone buzzing at four in the morning for a step that
|
||||
// will still be failed at breakfast is a notification people switch off
|
||||
// wholesale, and switching it off wholesale is how the one that mattered is
|
||||
// missed. Mail and the inbox both wait.
|
||||
channels: ['email', 'inapp'],
|
||||
// The generic body plus the structural projection: `event.run.failed`
|
||||
// declares its own `title` and a `runUrl`, so an unauthored mail is already
|
||||
// headed with the event's name and buttoned through to the run console —
|
||||
// §4.6.1 property 1, working exactly as it promises.
|
||||
template_keys: { email: 'notify.event', inapp: 'inapp.event' },
|
||||
// **No cooldown, and this is the one rule in the file that must not have
|
||||
// one.** The subject is the run, so a cooldown would only ever suppress a
|
||||
// second failure of the SAME run — which is precisely the run an
|
||||
// administrator most needs the second line about.
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Seed one group of rules, once, under its own guard key.
|
||||
*
|
||||
@@ -159,8 +234,15 @@ const NEWS_RULES = [
|
||||
async function seedGroup(key, rules, note) {
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
try {
|
||||
const seen = await settingsDb.get(key)
|
||||
if (seen) return { ...summary, skipped: rules.length }
|
||||
// **Claimed BEFORE the loop, atomically**, and the stamp is the claim. A
|
||||
// `get()` here with a `set()` after the inserts is not a guard when two
|
||||
// instances boot together — both read "absent", both seed — and a duplicate
|
||||
// rule is two mails per event. `claim()` is an `INSERT IGNORE` reporting its
|
||||
// own `affectedRows`, so exactly one caller proceeds. See the note below on
|
||||
// what a partial run costs: that trade is unchanged, only its ordering.
|
||||
if (!(await settingsDb.claim(key, new Date().toISOString()))) {
|
||||
return { ...summary, skipped: rules.length }
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
@@ -178,13 +260,13 @@ async function seedGroup(key, rules, note) {
|
||||
})
|
||||
summary.inserted += 1
|
||||
} catch (err) {
|
||||
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
|
||||
log.error('rule seed failed', { key, trigger: rule.trigger_id, message: err.message })
|
||||
}
|
||||
}
|
||||
// Stamped even on a partial run. Re-running would duplicate the rules that
|
||||
// did insert, and a duplicate rule is two mails per event — a worse outcome
|
||||
// than the one missing rule an operator can add from the screen.
|
||||
await settingsDb.set(key, new Date().toISOString())
|
||||
// Stamped even on a partial run — the claim above is the stamp. Re-running
|
||||
// would duplicate the rules that did insert, and a duplicate rule is two
|
||||
// mails per event, a worse outcome than the one missing rule an operator can
|
||||
// add from the screen.
|
||||
if (summary.inserted) {
|
||||
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
|
||||
}
|
||||
@@ -202,6 +284,10 @@ const seedTeamRules = () =>
|
||||
const seedNewsRule = () =>
|
||||
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
|
||||
|
||||
/** The two event-lifecycle rules (EVENTS.md Phase 10). */
|
||||
const seedEventRules = () =>
|
||||
seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one')
|
||||
|
||||
/**
|
||||
* Both groups, which is what the boot path calls.
|
||||
*
|
||||
@@ -212,9 +298,10 @@ const seedNewsRule = () =>
|
||||
async function seedCoreRules() {
|
||||
const team = await seedTeamRules()
|
||||
const news = await seedNewsRule()
|
||||
const events = await seedEventRules()
|
||||
return {
|
||||
inserted: team.inserted + news.inserted,
|
||||
skipped: team.skipped + news.skipped,
|
||||
inserted: team.inserted + news.inserted + events.inserted,
|
||||
skipped: team.skipped + news.skipped + events.skipped,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +309,11 @@ module.exports = {
|
||||
seedCoreRules,
|
||||
seedTeamRules,
|
||||
seedNewsRule,
|
||||
seedEventRules,
|
||||
RULES,
|
||||
NEWS_RULES,
|
||||
EVENT_RULES,
|
||||
SEEDED_KEY,
|
||||
NEWS_SEEDED_KEY,
|
||||
EVENT_SEEDED_KEY,
|
||||
}
|
||||
|
||||
@@ -150,13 +150,21 @@ async function applyRule(rule, event, now) {
|
||||
|
||||
// G24, re-run at send time. A rule saved when its trigger permitted a wider
|
||||
// audience must not keep reaching it after a module upgrade narrowed the
|
||||
// declaration - and that is the only way this can fail, since the save path
|
||||
// declaration - and that was the only way this could fail, since the save path
|
||||
// ran the same check.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
|
||||
//
|
||||
// **Phase 10 gave it a second way, and it is the one that fires in practice:**
|
||||
// the event may carry a narrowing ceiling of its own. A rehearsal emits
|
||||
// `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote
|
||||
// for the real thing is then refused here rather than mailing subscribers about
|
||||
// an event that is not happening. Nothing about the rule changed; the occasion
|
||||
// did. See `audiences.permitted`.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) {
|
||||
log.warn('rule audience exceeds its trigger ceiling - refusing', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
audience: resolved.ceiling,
|
||||
emitted: event.ceiling || null,
|
||||
})
|
||||
summary.skipped = 'ceiling'
|
||||
return summary
|
||||
@@ -203,9 +211,18 @@ async function applyRule(rule, event, now) {
|
||||
summary.capped += 1
|
||||
continue
|
||||
}
|
||||
// One statement, guarded on the interval, so two concurrent emits cannot
|
||||
// both pass a read-then-write check (§4.1).
|
||||
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
|
||||
// Guarded on the interval, so two concurrent emits cannot both pass a
|
||||
// read-then-write check (§4.1).
|
||||
//
|
||||
// **Keyed on the CHANNEL as well**, which is what makes this loop correct
|
||||
// rather than what makes it work. Without the channel, the first channel of
|
||||
// a rule claims the cooldown and every later one is refused as cooling —
|
||||
// and `inapp` is ranked first above, so a rule naming email + in-app would
|
||||
// deliver the in-app item and silently never the mail. Found on Phase 11b's
|
||||
// live rig; a cooldown is per delivery, not per occasion.
|
||||
const allowed = await cooldownsDb.claim(
|
||||
rule.id, userId, subjectKey, channel, rule.cooldown_seconds, now,
|
||||
)
|
||||
if (!allowed) {
|
||||
summary.cooled += 1
|
||||
continue
|
||||
|
||||
194
server/src/engagement/moduleSeeds.js
Normal file
194
server/src/engagement/moduleSeeds.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// ── Seeding what a module ships (ENGAGEMENT.md Phase 11b, decision 7) ──────
|
||||
//
|
||||
// Core's own bodies and rules are seeded from `seedDefaults()`, and a module's
|
||||
// cannot be: `server.js` calls `seedDefaults()` BEFORE it requires `app.js`, and
|
||||
// requiring `app.js` is what scans the volume and runs the loader. At the moment
|
||||
// core seeds, no module has registered anything at all.
|
||||
//
|
||||
// So this runs from `modules/lifecycle.js` `boot()` instead — after the
|
||||
// `installed_modules` reconcile, so a module the operator disabled or one that
|
||||
// failed to load is skipped rather than seeded, and BEFORE the `onBoot`
|
||||
// dispatch, so a module that warms a cache in `onBoot` may assume its rules
|
||||
// exist.
|
||||
//
|
||||
// **It reuses core's two seeders rather than reimplementing them**, which is the
|
||||
// whole argument for the registry existing (decision 7): `seedOne` owns the
|
||||
// `customized` skip and the `seed_version` comparison, `validateEmailBlocks`
|
||||
// owns what a renderable body is, and a module supplies data. A copy of either
|
||||
// living outside this directory would drift the first time core improved the
|
||||
// original — and the drift would surface as a mail somebody already received.
|
||||
//
|
||||
// ── The asymmetry, once more, because it is the thing to get right ─────────
|
||||
//
|
||||
// **Templates are re-ensured every boot.** A row carries `seed_key`,
|
||||
// `seed_version` and `customized`, so re-ensuring is how a better default
|
||||
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
|
||||
// and a template added in a later module version reaches every deployment rather
|
||||
// than only fresh ones.
|
||||
//
|
||||
// **Rule groups are one-shot, each under its own settings guard.** Re-ensuring a
|
||||
// rule would resurrect one an operator deleted and reset one they enabled. This
|
||||
// is 11a's seed-key finding as a mechanism: a rule appended to an existing group
|
||||
// reaches fresh installs only, and a rule that must reach already-stamped
|
||||
// deployments takes a new group key. The module chooses; this file honours it.
|
||||
//
|
||||
// **Never throws.** It is on the boot path beside every other `safe()`-wrapped
|
||||
// step in `lifecycle.boot()`, and a body that would not seed costs the shipped
|
||||
// default — `renderByKey`'s fallback stays in charge — not the deployment.
|
||||
|
||||
const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* The one-shot guard for one module's rule group.
|
||||
*
|
||||
* Namespaced by owner AND by group so two modules may use the same group name,
|
||||
* and so a module can add a second group later without touching the first. Its
|
||||
* VALUE is the timestamp — purely so an operator reading the settings table can
|
||||
* tell when it ran; only its presence is read.
|
||||
*/
|
||||
const guardKey = (owner, group) => `engagement_module_rules_seeded:${owner}:${group}`
|
||||
|
||||
/**
|
||||
* Ensure one module's templates, and bring un-customized rows up to the current
|
||||
* seed. Idempotent.
|
||||
*/
|
||||
async function seedModuleTemplates(owner, templates, deps = {}) {
|
||||
const templates_ = deps.templatesDb || templatesDb
|
||||
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
|
||||
for (const seed of templates) {
|
||||
// Validated against the block registry before it is stored, exactly as core's
|
||||
// own seeds are and for the same reason: a shipped block array no renderer
|
||||
// understands sitting in the table reads to an operator as their deployment
|
||||
// being broken. Refusing to write it leaves the fallback in charge and puts
|
||||
// the reason in the boot log, with the module named.
|
||||
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
||||
if (!valid) {
|
||||
log.error('a module template is invalid and was not seeded', { owner, key: seed.key, errors })
|
||||
counts.invalid += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
counts[await templates_.seedOne(seed)] += 1
|
||||
} catch (err) {
|
||||
log.error('module template seed failed', { owner, key: seed.key, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// The third arm of §4.6.1 property 3: a customized row is never touched, and
|
||||
// the fact that a better default now exists is surfaced instead of applied.
|
||||
let stale = []
|
||||
try {
|
||||
stale = await templates_.staleCustomized(
|
||||
templates.map((t) => ({ key: t.key, seedVersion: t.seedVersion })),
|
||||
)
|
||||
} catch {
|
||||
stale = []
|
||||
}
|
||||
if (stale.length) {
|
||||
log.info('customized module templates have a newer shipped default', {
|
||||
owner,
|
||||
keys: stale.map((t) => t.key),
|
||||
})
|
||||
}
|
||||
return { ...counts, stale: stale.map((t) => t.key) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one named rule group, once, under its own guard.
|
||||
*
|
||||
* Mirrors `coreRules.seedGroup` deliberately, including the claim-before-insert
|
||||
* ordering and what it costs: re-running would duplicate the rules that DID
|
||||
* insert, and a duplicate rule is two mails per event — worse than the one
|
||||
* missing rule an operator can add from the Rules screen. The guard is taken
|
||||
* atomically for the same reason; see the comment at the claim.
|
||||
*/
|
||||
async function seedRuleGroup(owner, group, deps = {}) {
|
||||
const rules_ = deps.rulesDb || rulesDb
|
||||
const settings_ = deps.settingsDb || settingsDb
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
const key = guardKey(owner, group.key)
|
||||
try {
|
||||
// **Claim BEFORE inserting, not after.** The guard used to be a `get()` here
|
||||
// and a `set()` after the loop, which is not a guard under concurrency: two
|
||||
// processes starting in the same moment both read "absent" and both insert
|
||||
// the whole group. That is not hypothetical — `docker compose up
|
||||
// --scale app=2` and a rolling restart both boot two instances deliberately,
|
||||
// and Phase 13's acceptance walk hit it with two, ending up with 52 module
|
||||
// rules where the module ships 26. `claim()` is an `INSERT IGNORE` reporting
|
||||
// its own `affectedRows`, so exactly one caller wins.
|
||||
//
|
||||
// The cost is the one this function already accepted below: a process that
|
||||
// dies mid-loop leaves the group stamped and partly seeded, and the missing
|
||||
// rules are an operator's visit to the "new rule" form. Duplicates are the
|
||||
// worse failure — two mails per event, for every rule in the group — which
|
||||
// is why the order is this way round rather than the other.
|
||||
if (!(await settings_.claim(key, new Date().toISOString()))) {
|
||||
return { ...summary, skipped: group.rules.length }
|
||||
}
|
||||
|
||||
for (const rule of group.rules) {
|
||||
try {
|
||||
await rules_.insert(rule)
|
||||
summary.inserted += 1
|
||||
} catch (err) {
|
||||
log.error('module rule seed failed', {
|
||||
owner,
|
||||
group: group.key,
|
||||
trigger: rule.trigger_id,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (summary.inserted) {
|
||||
log.info('seeded module engagement rules, all disabled', {
|
||||
owner,
|
||||
group: group.key,
|
||||
rules: summary.inserted,
|
||||
note: group.note || undefined,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('module rule group seeding failed', { owner, group: group.key, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed every registered module's engagement content.
|
||||
*
|
||||
* @param {object} [deps]
|
||||
* @param {Function} [deps.seeds] () => [{ owner, templates, ruleGroups }]
|
||||
* @param {Set} [deps.skip] owners not to seed (disabled or failed)
|
||||
* @param {object} [deps.templatesDb] / [deps.rulesDb] / [deps.settingsDb] — test seams
|
||||
*/
|
||||
async function seedModuleEngagement({ seeds, skip = new Set(), ...dbs } = {}) {
|
||||
// eslint-disable-next-line global-require
|
||||
const read = seeds || require('../modules/registries').allEngagementSeeds
|
||||
const totals = { templates: 0, rules: 0 }
|
||||
|
||||
for (const entry of read()) {
|
||||
if (skip.has(entry.owner)) {
|
||||
log.info('skipping engagement seeds for a module that is not booting', { owner: entry.owner })
|
||||
continue
|
||||
}
|
||||
const t = await seedModuleTemplates(entry.owner, entry.templates || [], dbs)
|
||||
totals.templates += t.inserted + t.updated
|
||||
for (const group of entry.ruleGroups || []) {
|
||||
const r = await seedRuleGroup(entry.owner, group, dbs)
|
||||
totals.rules += r.inserted
|
||||
}
|
||||
log.info('module engagement seeds ensured', { owner: entry.owner, ...t })
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedModuleEngagement,
|
||||
seedModuleTemplates,
|
||||
seedRuleGroup,
|
||||
guardKey,
|
||||
}
|
||||
@@ -44,6 +44,27 @@ const AMBIENT_VARIABLES = Object.freeze([
|
||||
{ name: 'year', type: 'string', required: true, example: '2026' },
|
||||
])
|
||||
|
||||
|
||||
// The per-DELIVERY additions, which are a different thing from the ambient set
|
||||
// above and are declared separately because they apply to a different set of
|
||||
// templates.
|
||||
//
|
||||
// `emailChannel.deliver` computes an unsubscribe token per recipient and merges
|
||||
// it LAST over the projection, so a body may always reference it — but a template
|
||||
// bound to a TRIGGER takes its variable list from that trigger's declaration
|
||||
// (`variablesFor`), and a trigger has no business declaring a fact about how the
|
||||
// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and
|
||||
// then the save-time undeclared-variable check refuses the first operator who
|
||||
// tries to EDIT the body around it.
|
||||
//
|
||||
// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first
|
||||
// trigger-bound templates in the system to carry an unsubscribe line of their
|
||||
// own: core's generic `notify.event` declares it in its own seed and is bound to
|
||||
// no trigger, so nothing had ever taken this path.
|
||||
const DELIVERY_VARIABLES = Object.freeze([
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
])
|
||||
|
||||
// A tiny helper so the block arrays below read as content rather than as JSON.
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
@@ -289,6 +310,63 @@ const SEEDS = [
|
||||
button('cta', 'Open', '{{actionUrl}}'),
|
||||
],
|
||||
},
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ─────────────────────────
|
||||
//
|
||||
// **One body, not seven.** Six of the seven `event.` triggers render through
|
||||
// `notify.event` and the structural projection with no authoring at all
|
||||
// (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail
|
||||
// is already headed with the event's name — and seeding a bespoke body per
|
||||
// trigger would be seven templates an operator has to maintain to change one
|
||||
// sentence.
|
||||
//
|
||||
// `event.run.started` gets one because it is the flagship: the mail that
|
||||
// answers §8.5's *"Come back for X — a scheduled event is starting"*, the one
|
||||
// an operator will actually enable, and the one where the generic body reads
|
||||
// visibly worse — `notify.event` renders the title over the TRIGGER's
|
||||
// description, while this reads the payload's own names and says what is
|
||||
// starting, when, and what arc it belongs to. Same argument `notify.team-post`
|
||||
// makes beside the generic body, one feature along.
|
||||
//
|
||||
// **Every optional line is one token on its own**, which is this template
|
||||
// language's whole conditional (see `email.text`: a block whose content is a
|
||||
// single absent variable renders nothing, in both parts). A standalone event
|
||||
// has no `seriesName` and its line disappears rather than reading "Part of .".
|
||||
//
|
||||
// **The button arrived with the page it points at** (Phase 14a). Until then
|
||||
// there was no public event page, the six public triggers declared no url
|
||||
// variable, and a button here would have rendered as an inert grey label in
|
||||
// every mail — worse than no button, because it advertises a link the reader
|
||||
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
|
||||
// its url interpolates to nothing, so an event that is not public still mails
|
||||
// correctly: the block disappears rather than degrading.
|
||||
{
|
||||
key: 'notify.event-started',
|
||||
name: 'Event starting',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
// Bumped with the button. A deployment whose operator has not customized
|
||||
// this template gets the new one; one that has is left alone and reported as
|
||||
// stale, which is the whole mechanism.
|
||||
seedVersion: 2,
|
||||
subject: '{{title}} is starting',
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
|
||||
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
|
||||
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}'),
|
||||
text('summary', '{{summary}}'),
|
||||
text('when', '{{startsAtLabel}}', { muted: true }),
|
||||
text('series', '{{seriesName}}', { muted: true }),
|
||||
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** @returns {object|null} the seed definition for `key`. */
|
||||
@@ -296,4 +374,4 @@ function seedByKey(key) {
|
||||
return SEEDS.find((s) => s.key === key) || null
|
||||
}
|
||||
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }
|
||||
|
||||
@@ -18,7 +18,7 @@ const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const brand = require('../config/brand')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
|
||||
const { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } = require('./templateSeeds')
|
||||
// The trigger registry lives with the module registries, not here — a trigger is
|
||||
// something a MODULE declares (see engagement/index.js's header).
|
||||
const { eventTrigger } = require('../modules/registries')
|
||||
@@ -84,6 +84,11 @@ function variablesFor(template) {
|
||||
if (template && template.trigger_id) {
|
||||
const declared = eventTrigger(template.trigger_id)
|
||||
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
|
||||
// A trigger-bound body is engagement mail, and engagement mail always carries
|
||||
// an unsubscribe the channel computes per recipient. A trigger declares what
|
||||
// HAPPENED and has no business declaring how the mail was sent, so the
|
||||
// delivery facts are added here rather than to every declaration.
|
||||
own.push(...DELIVERY_VARIABLES)
|
||||
} else if (template && template.seed_key) {
|
||||
const seed = seedByKey(template.seed_key)
|
||||
if (seed) own.push(...seed.variables)
|
||||
|
||||
258
server/src/events/announce.js
Normal file
258
server/src/events/announce.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// ── A run's lifecycle, told to the engagement engine ───────────────────────
|
||||
//
|
||||
// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life
|
||||
// become seven `event.` triggers, and **Events owns none of the delivery**.
|
||||
//
|
||||
// That sentence is the whole design and it is worth being exact about what it
|
||||
// buys. Nothing in this file knows what email is, whether anyone is subscribed,
|
||||
// what a template says, or how often somebody may be mailed. It says a thing
|
||||
// happened, with the facts the declaration asked for; an operator's rule decides
|
||||
// the rest. Every announcement channel the platform has — email, the in-app
|
||||
// inbox, content-free push tickles, Discord and the town crier through the
|
||||
// announce legs — arrives for free the day a rule points at one, and none of
|
||||
// them arrives by anything in `events/` growing a second delivery path.
|
||||
//
|
||||
// **Nothing here throws and nothing here is awaited for its answer.** `emit`
|
||||
// itself is fire-and-forget by construction (see `engagementEmit`'s header) —
|
||||
// the whole point of the seam is that the emitter does not wait on rule lookups
|
||||
// and a dozen inserts. What IS awaited here is the read that assembles the
|
||||
// payload, and it is wrapped: a run must not fail to start because the row that
|
||||
// says what it is called could not be read.
|
||||
//
|
||||
// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for
|
||||
// real with announcements ceilinged to `staff`". Every emit below carries
|
||||
// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the
|
||||
// same rules are evaluated, the same log lines are written — and the only rules
|
||||
// that survive the G24 gate are ones whose audience a staff member is in. A
|
||||
// rehearsal that emitted nothing would be a rehearsal of everything except the
|
||||
// announcements, which are the part most worth rehearsing.
|
||||
|
||||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const engagementEmit = require('../utils/engagementEmit')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// §I, and the one place a rehearsal differs from the real thing on the announce
|
||||
// path. `staff` rather than `admin` because a rehearsal is the event team's
|
||||
// dress run and a moderator on it should see what an attendee would.
|
||||
const REHEARSAL_CEILING = 'staff'
|
||||
|
||||
/**
|
||||
* The start time written out in the shard-local zone, for a mail to read.
|
||||
*
|
||||
* **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1
|
||||
* convention 1). `startsAt` also goes down the wire as a `datetime`, which the
|
||||
* seam normalises to an ISO string — right as data and wrong in a sentence — and
|
||||
* a template has no logic with which to format one. The zone is the shard's own,
|
||||
* because "8pm" means the shard's evening to everyone reading it and the
|
||||
* recipient's browser is not in the room when a mail is rendered.
|
||||
*
|
||||
* A bad zone answers null rather than throwing: `Intl` rejects an unknown
|
||||
* identifier, and an event whose timezone column holds a typo must still
|
||||
* announce. The variable is optional and its block is one token, so an absent
|
||||
* label renders as nothing at all rather than as a broken line.
|
||||
*/
|
||||
function startsAtLabel(at, zone) {
|
||||
const when = at instanceof Date ? at : new Date(at)
|
||||
if (Number.isNaN(when.getTime())) return undefined
|
||||
try {
|
||||
const text = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone || 'UTC',
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
// Explicit rather than left to the locale, because `en-GB` would otherwise
|
||||
// render midnight as "00:00" while the schedule editor beside it writes
|
||||
// "12:00 AM" — one event, two spellings of the same instant.
|
||||
//
|
||||
// `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
|
||||
// and does not survive a Node upgrade. For a locale whose default cycle is
|
||||
// h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
|
||||
// hours run 0–11, so midnight comes out "0:00 am"; Node 22 and later
|
||||
// resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
|
||||
// is V8's ECMA-402 behaviour and not locale data, and the image ships
|
||||
// node:20-alpine while a dev machine is newer — which is how this rendered
|
||||
// correctly in front of everyone who wrote it and wrongly for every real
|
||||
// recipient. `recurrence.js` states the mirror-image rule for `h23`; there
|
||||
// is no `hour12` left in this repo and it should stay that way.
|
||||
hourCycle: 'h12',
|
||||
}).format(when)
|
||||
return `${text} (${zone || 'UTC'})`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts every `event.` trigger shares, read once per emit.
|
||||
*
|
||||
* A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no
|
||||
* series, no summary — so the definition is fetched here rather than threaded
|
||||
* through every call site in the runner. It is one indexed read per lifecycle
|
||||
* transition, which is a handful per run.
|
||||
*/
|
||||
async function baseFor(run) {
|
||||
const definition = await definitionsDb.getById(run.definition_id)
|
||||
if (!definition) return null
|
||||
return {
|
||||
runId: String(run.id),
|
||||
title: definition.title,
|
||||
summary: definition.summary || undefined,
|
||||
seriesName: definition.series_name || undefined,
|
||||
timezone: run.timezone || definition.timezone || undefined,
|
||||
eventUrl: eventUrl(definition, run),
|
||||
definition,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public page for one occurrence (Phase 14a).
|
||||
*
|
||||
* **Site-relative, and it carries the run.** The page lives at the definition's
|
||||
* slug — one stable address for a weekly event, which is what makes a link in
|
||||
* Discord survive a retitle — so the occurrence has to be in the query string or
|
||||
* a mail about last Friday's invasion would open next Friday's.
|
||||
*
|
||||
* **`undefined` when the event is not public**, rather than a path that answers
|
||||
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
|
||||
* declared optional precisely so its block can disappear from a template instead
|
||||
* of rendering a dead button. That is `news.post`'s lesson applied before it
|
||||
* costs anything: a link nobody can follow is worse than no link, because it
|
||||
* advertises one.
|
||||
*/
|
||||
function eventUrl(definition, run) {
|
||||
if (!definition.slug) return undefined
|
||||
if (definition.state !== 'ready' || !definition.listed) return undefined
|
||||
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one lifecycle trigger.
|
||||
*
|
||||
* `extra` is merged over the shared facts and may drop any of them — a payload
|
||||
* key set to `undefined` is simply absent, and `validatePayload` treats absent
|
||||
* and null alike, so a trigger that declares fewer variables than this assembles
|
||||
* is not a problem: undeclared keys are dropped at the seam and logged as a
|
||||
* debug line rather than refused.
|
||||
*
|
||||
* Answers nothing. Every caller is a transition in the runner and none of them
|
||||
* has anything it could correctly do with a failure of core's own notification
|
||||
* bookkeeping.
|
||||
*/
|
||||
async function fire(run, triggerId, extra = {}) {
|
||||
try {
|
||||
const base = await baseFor(run)
|
||||
if (!base) {
|
||||
// The definition is gone. `event_runs.definition_id` cascades on delete, so
|
||||
// this is a race with an archive rather than an ordinary state — nothing to
|
||||
// announce and nothing broken.
|
||||
return
|
||||
}
|
||||
const { definition, ...facts } = base
|
||||
const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined
|
||||
|
||||
engagementEmit.emit('core', triggerId, {
|
||||
// The run, not the definition. Two occurrences of a weekly event are two
|
||||
// subjects, so last week's mail does not throttle this week's — and within
|
||||
// one run a cooldown means "at most one line an hour about THIS", which is
|
||||
// the sentence an operator writing `phase.changed` actually wants.
|
||||
subject: String(run.id),
|
||||
// Bounded and stable, because it ends up in a signed unsubscribe token that
|
||||
// will sit in a mailbox for months. A run id is both.
|
||||
scopeKey: `event:${run.id}`,
|
||||
ceiling,
|
||||
data: { ...facts, ...extra },
|
||||
})
|
||||
|
||||
// Written here rather than at each call site: what an operator wants in the
|
||||
// run log is that the run SAID something happened, and with what bound. How
|
||||
// many people were told is the engagement engine's own log line and its own
|
||||
// decision — a run log that claimed to know the number would be reporting a
|
||||
// decision it does not make.
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'announcement.emitted',
|
||||
phase: run.current_phase || null,
|
||||
detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) },
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── One function per moment, so the runner names a moment and not a payload ──
|
||||
//
|
||||
// The alternative — `fire(run, 'event.run.started', { … })` at each call site —
|
||||
// would put the payload assembly in `eventRunner.js`, where a change to a
|
||||
// declaration becomes a change to the runner. These are the seam.
|
||||
|
||||
const runScheduled = (run) =>
|
||||
fire(run, 'event.run.scheduled', {
|
||||
startsAt: run.scheduled_for,
|
||||
startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone),
|
||||
})
|
||||
|
||||
const runStarted = (run, startedAt) => {
|
||||
const at = startedAt || run.started_at || new Date()
|
||||
return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) })
|
||||
}
|
||||
|
||||
const phaseChanged = (run, { phase, label, index, count }) =>
|
||||
fire(run, 'event.phase.changed', {
|
||||
phase,
|
||||
phaseLabel: label || phase,
|
||||
// One-based, because it is read by a human in a sentence. Every caller
|
||||
// passes the zero-based index it already has and the conversion is here, in
|
||||
// one place, rather than at three call sites where two of them would drift.
|
||||
phaseIndex: index + 1,
|
||||
phaseCount: count,
|
||||
})
|
||||
|
||||
const runEnding = (run) => fire(run, 'event.run.ending')
|
||||
|
||||
async function runCompleted(run, endedAt) {
|
||||
// Counted at emit rather than carried by the caller: the last thing a run does
|
||||
// before completing is its teardown, and a module's collect step may have
|
||||
// written rows within the same tick.
|
||||
let participantCount = 0
|
||||
try {
|
||||
participantCount = await participantsDb.countForRun(run.id)
|
||||
} catch (err) {
|
||||
// Declared `required`, so it has to be a number. Zero is the honest answer
|
||||
// for a count that could not be read, and it is also the answer for the far
|
||||
// more common case of a run nothing collected for.
|
||||
log.warn('participant count unavailable for announcement', { run: run.id, message: err.message })
|
||||
}
|
||||
const started = run.started_at ? new Date(run.started_at) : null
|
||||
const ended = endedAt ? new Date(endedAt) : new Date()
|
||||
const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0
|
||||
return fire(run, 'event.run.completed', { participantCount, durationMinutes })
|
||||
}
|
||||
|
||||
const runCancelled = (run, reason) =>
|
||||
fire(run, 'event.run.cancelled', { reason: reason || undefined })
|
||||
|
||||
const runFailed = (run, error) =>
|
||||
fire(run, 'event.run.failed', {
|
||||
phase: run.current_phase || undefined,
|
||||
error: error || run.last_error || undefined,
|
||||
// The one destination that exists today. See `coreTriggers.js`'s note above
|
||||
// the six public declarations for why none of them has one.
|
||||
runUrl: `/admin/events/runs/${run.id}`,
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
fire,
|
||||
startsAtLabel,
|
||||
runScheduled,
|
||||
runStarted,
|
||||
phaseChanged,
|
||||
runEnding,
|
||||
runCompleted,
|
||||
runCancelled,
|
||||
runFailed,
|
||||
REHEARSAL_CEILING,
|
||||
}
|
||||
400
server/src/events/authorize.js
Normal file
400
server/src/events/authorize.js
Normal file
@@ -0,0 +1,400 @@
|
||||
// ── The whole authorisation decision, behind one function ──────────────────
|
||||
//
|
||||
// EVENTS.md §K, and Phase 6 of EVENTS_PLAN.md. Four layers stand between an
|
||||
// action being *declared* and an action being *carried out* — role, enablement,
|
||||
// cap, and the shard's own switch — and §K asks for them in one place rather
|
||||
// than spread across route middleware:
|
||||
//
|
||||
// > **Keep the check in one function.** Not for tidiness: it is what makes an
|
||||
// > EM-style delegation model a *later* option rather than a redesign. If a
|
||||
// > deployment ever wants named coordinators with their own budgets, that is
|
||||
// > one function learning to consult a second table, and nothing else in this
|
||||
// > document changes.
|
||||
//
|
||||
// So `mayInvoke()` below is the only thing in this codebase that answers "may
|
||||
// this happen". The router still calls `requireRole` — that gate is about
|
||||
// reaching the ROUTE — but whether a particular verb may be aimed at the world is
|
||||
// decided here, once, on every path that can cause it: authoring a step,
|
||||
// publishing, the dry run, starting a run, and the runner's own unattended
|
||||
// dispatch.
|
||||
//
|
||||
// ## Four layers, and where each one is actually enforced
|
||||
//
|
||||
// 1. **Declaration** — a module says a verb exists. Not a permission, and not
|
||||
// checked here: `dispatch.js` already answers a step whose action nobody
|
||||
// registers, and it answers it `dormant` rather than `refused`, because an
|
||||
// uninstalled module is a different fact from a forbidden one.
|
||||
// 2. **Role** — `change` and `irreversible` are `admin` only. See below.
|
||||
// 3. **Enablement and caps** — this file, against `event_action_settings` and
|
||||
// `event_run_budget`.
|
||||
// 4. **The shard's own switches** — `AdminWriteEnabled` and `AdminAccessFloor`
|
||||
// live on the shard host, outside the website's reach entirely, and core
|
||||
// deliberately does not duplicate them. A module honours them when it
|
||||
// translates an action into a sidecar command (P9); a second copy of that
|
||||
// decision in core would be a copy that could disagree with the shard about
|
||||
// whether the shard is accepting writes. It is named as a layer because
|
||||
// leaving it unnamed is how it comes to be re-implemented.
|
||||
//
|
||||
// ## The role line, and why it is drawn at `change`
|
||||
//
|
||||
// §K's table says "any step whose action is above `notify`, and the action
|
||||
// switchboard — `admin` only". Read literally that is the same sentence that made
|
||||
// `core.wait` — `risk: 'inspect'` — ship disabled by default, and the org lead
|
||||
// settled that on 2026-09-03: the line falls between `inspect` and `change`, not
|
||||
// between `notify` and `inspect`. An `inspect` action reads state and writes
|
||||
// nothing, so neither the default nor the role floor gains a deployment anything
|
||||
// by excluding it, and an editor who cannot author a step that WAITS has an
|
||||
// authoring role that cannot author.
|
||||
//
|
||||
// ## Why `user` may be null, and what that means
|
||||
//
|
||||
// The runner dispatches with nobody logged in. It is not "the system escalating":
|
||||
// the role was checked when a human published the version and again when a human
|
||||
// or the scheduler started the run, and **a run already in flight is not re-gated
|
||||
// against its starter's current role**. Re-checking would mean that demoting an
|
||||
// admin at midnight silently strands every event they started — an event stopping
|
||||
// halfway through because of an unrelated personnel change. §K's "a demoted user
|
||||
// loses access at once" is about reaching a route, and it still holds exactly
|
||||
// there. Cancel is the control for a run that should stop.
|
||||
//
|
||||
// ## Why the cap check can spend
|
||||
//
|
||||
// `mayInvoke` reads on every path but ONE, and on that one it must also write.
|
||||
// The cap is held by a conditional `UPDATE` whose WHERE carries the guard (§E), so
|
||||
// checking and then spending would be two statements with a race between them —
|
||||
// the exact race the conditional increment exists to remove. `spend: true` is
|
||||
// therefore a parameter rather than a separate function: one decision procedure,
|
||||
// one set of layers, and the authoritative check is the one that also commits.
|
||||
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../model/events/eventRunBudget.db')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// The risk classes that change the world, and the two things that follow from
|
||||
// being on this list: the action arrives DISABLED on a fresh deployment, and only
|
||||
// an admin may author a step that names it. Both were one sentence in §K and both
|
||||
// were settled together (org lead, 2026-09-03).
|
||||
const WORLD_CHANGING = ['change', 'irreversible']
|
||||
|
||||
/** Does this action alter the world, in the sense the switchboard and the role floor mean? */
|
||||
const changesWorld = (action) => WORLD_CHANGING.includes(action?.risk)
|
||||
|
||||
/**
|
||||
* Whether an action is enabled, given the deployment's stored opinion — or, when
|
||||
* it has none, its risk class.
|
||||
*
|
||||
* Exported because the switchboard renders the same answer, and a screen that
|
||||
* computed the default itself would be a second copy of the posture.
|
||||
*/
|
||||
function isEnabled(action, settingsRow) {
|
||||
if (settingsRow) return Boolean(settingsRow.enabled)
|
||||
return !changesWorld(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one invocation of `action` costs, as `{dimension: amount}`.
|
||||
*
|
||||
* **A module's `cost()` is called here and nowhere else.** It is declared as a
|
||||
* function of params (§F) and it is called with the params a step actually
|
||||
* carries, so the number core enforces is the number the module said. A `cost`
|
||||
* that throws, or that answers something other than a flat object of
|
||||
* non-negative finite numbers, is treated as an unpriceable action rather than a
|
||||
* free one: `null` comes back, and every caller reads `null` as a refusal. That
|
||||
* is the fail-closed direction, and it is the only honest one — an action whose
|
||||
* own accounting is broken is not an action whose consumption is zero.
|
||||
*/
|
||||
function priceOf(action, params) {
|
||||
if (typeof action?.cost !== 'function') return {}
|
||||
let raw
|
||||
try {
|
||||
raw = action.cost(params || {})
|
||||
} catch (err) {
|
||||
log.warn('event action cost() threw', { action: action.id, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
for (const [dimension, amount] of Object.entries(raw)) {
|
||||
const n = Number(amount)
|
||||
if (!Number.isFinite(n) || n < 0) return null
|
||||
if (n > 0) out[dimension] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimensions an action can spend, discovered by pricing its declared
|
||||
* examples.
|
||||
*
|
||||
* **Phase 7 replaced half of this and deliberately kept the other half.** §F's
|
||||
* `registerEventBudgets` now declares a dimension's id, label and unit, so the
|
||||
* switchboard no longer has to invent a name for a box — see `budgetsOf` below,
|
||||
* which is what the screen reads. What a registry cannot answer is *which*
|
||||
* dimensions THIS action spends, because `cost` is a function of params (§F) and
|
||||
* the only honest way to ask it is to call it. So the discovery stays: core
|
||||
* prices each action's own `example` values, which is a use every param already
|
||||
* has a required `example` for.
|
||||
*
|
||||
* It is honest about its limits: a `cost()` that returns different dimension KEYS
|
||||
* for different params under-reports here. That costs an operator a cap box on
|
||||
* the switchboard, and it costs a run nothing at all — a run's budget is seeded
|
||||
* from the params its steps were actually authored with, never from examples.
|
||||
*/
|
||||
function dimensionsOf(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
|
||||
}
|
||||
const priced = priceOf(action, params)
|
||||
return priced ? Object.keys(priced).sort() : []
|
||||
}
|
||||
|
||||
/**
|
||||
* The same dimensions, dressed with what the registry says they are called.
|
||||
*
|
||||
* The switchboard's read (Phase 7). `registered: false` is the case worth having
|
||||
* a field for: an action that prices a dimension no module declares is a
|
||||
* DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the
|
||||
* save refuses it — so the screen has to be able to show the operator the reason
|
||||
* their action will not run, rather than silently listing one fewer cap box than
|
||||
* the action has dimensions. Hiding it would make a broken module look like a
|
||||
* cheap one.
|
||||
*/
|
||||
function budgetsOf(action) {
|
||||
return dimensionsOf(action).map((id) => {
|
||||
const declared = registries.eventBudget(id)
|
||||
return declared
|
||||
? { id, label: declared.label, unit: declared.unit, registered: true }
|
||||
: { id, label: id, unit: '', registered: false }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.)
|
||||
*
|
||||
* Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a
|
||||
* rule only if something asks, and this is what asks — from `mayInvoke` at
|
||||
* dispatch and at the dry run, and from the spec validator at save. Three places
|
||||
* because they answer at three different moments and only the first of them is
|
||||
* cheap: catching it at save costs an editor a red line, catching it at dispatch
|
||||
* costs a run a refused step at two in the morning.
|
||||
*
|
||||
* Not folded into `priceOf`, which answers *what does this cost* and should keep
|
||||
* answering only that: a cost of 12 creatures is a true statement about the
|
||||
* action whether or not anyone declared the dimension, and the two facts have
|
||||
* different fixes.
|
||||
*/
|
||||
function undeclaredDimensions(cost) {
|
||||
return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d))
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective per-run cap for each dimension a set of steps will spend.
|
||||
*
|
||||
* **The tightest cap wins** (org lead, 2026-09-03). `event_action_settings.caps`
|
||||
* is per action while `event_run_budget` is one row per dimension, so two actions
|
||||
* both spending `uo.creatures` have to agree on one number, and the number a
|
||||
* safety limit should settle on is the smaller. It is what keeps a dimension a
|
||||
* bound on the RUN's total effect rather than a per-verb allowance that two verbs
|
||||
* can each draw in full.
|
||||
*
|
||||
* A dimension no action caps comes back `{ cap: null }` — uncapped, and still
|
||||
* seeded, so the meter counts it and a missing row keeps its one meaning.
|
||||
*
|
||||
* `steps` are `{ actionId, params }`; the answer is `{dimension: {cap, from}}`.
|
||||
*/
|
||||
function effectiveCaps(steps, settingsByAction) {
|
||||
const out = {}
|
||||
for (const step of steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) continue
|
||||
const priced = priceOf(action, step.params)
|
||||
if (!priced) continue
|
||||
const declared = (settingsByAction.get(action.id) || {}).caps || {}
|
||||
for (const dimension of Object.keys(priced)) {
|
||||
const raw = declared[dimension]
|
||||
const cap = Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : null
|
||||
if (!(dimension in out)) {
|
||||
out[dimension] = { cap, from: cap === null ? null : action.id }
|
||||
continue
|
||||
}
|
||||
const held = out[dimension]
|
||||
// `null` is uncapped, so it never wins a minimum — an action that declines
|
||||
// to cap a dimension must not raise the ceiling another action set.
|
||||
if (cap !== null && (held.cap === null || cap < held.cap)) {
|
||||
out[dimension] = { cap, from: action.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* May this action be carried out, and — when asked — spend its cost.
|
||||
*
|
||||
* Answers an envelope, never throws, and never answers a bare boolean: every
|
||||
* refusal carries a `code` a caller can branch on and a `reason` a human reads.
|
||||
* The reason is written here rather than at the four call sites for the same
|
||||
* argument Phase 5 made about the diagnosis panel — one place the words are
|
||||
* written, so the dry run, the editor, the run console and the log all say the
|
||||
* same sentence about the same fact.
|
||||
*
|
||||
* `{ user }` null means the unattended runner; see the header. `{ run }` null
|
||||
* means there is no budget to draw on yet — authoring and the dry run — and the
|
||||
* cap layer then compares the cost against the effective cap instead of against
|
||||
* what is left of it.
|
||||
*/
|
||||
async function mayInvoke({
|
||||
user = null,
|
||||
action,
|
||||
params = {},
|
||||
run = null,
|
||||
settings = undefined,
|
||||
spend = false,
|
||||
} = {}) {
|
||||
if (!action) return { ok: false, code: 'unregistered', reason: 'no module registers this action' }
|
||||
|
||||
// ── Layer 2: the role ──
|
||||
if (user && changesWorld(action) && user.role !== 'admin') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'role',
|
||||
reason: `"${action.label}" changes the world, so only an administrator may use it`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3a: enablement ──
|
||||
const row = settings === undefined ? await settingsDb.get(action.id) : settings
|
||||
if (!isEnabled(action, row)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'disabled',
|
||||
reason: `"${action.label}" is not enabled on this deployment`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3b: the cap ──
|
||||
const cost = priceOf(action, params)
|
||||
if (cost === null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unpriceable',
|
||||
reason: `"${action.label}" could not report what it costs`,
|
||||
}
|
||||
}
|
||||
const dimensions = Object.keys(cost)
|
||||
if (!dimensions.length) return { ok: true, cost }
|
||||
|
||||
// Before any cap arithmetic, because a dimension nobody declared has no cap to
|
||||
// be under and no meter to draw on — asking "is 12 within the limit" about a
|
||||
// resource core has never been told the name of would be answering a question
|
||||
// that has not been asked yet. It is also the honest reading of the refusal:
|
||||
// this is a module whose declaration is incomplete, not a deployment whose
|
||||
// allowance is spent, and an operator who is told the second will go and raise
|
||||
// a cap that changes nothing.
|
||||
const undeclared = undeclaredDimensions(cost)
|
||||
if (undeclared.length) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'undeclared',
|
||||
reason: `spends "${undeclared[0]}", which no module declares as a budget`,
|
||||
dimension: undeclared[0],
|
||||
requested: cost[undeclared[0]],
|
||||
}
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
// No run, so nothing to draw on: the question is whether the cost could EVER
|
||||
// fit, which is what the dry run and the editor are asking. A cost larger
|
||||
// than the cap is an authoring error and it is answerable before anything is
|
||||
// scheduled — which is the entire value of catching it here.
|
||||
const caps = effectiveCaps([{ actionId: action.id, params }], new Map([[action.id, row || {}]]))
|
||||
for (const dimension of dimensions) {
|
||||
const { cap } = caps[dimension] || { cap: null }
|
||||
if (cap !== null && cost[dimension] > cap) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${cost[dimension]} of "${dimension}" and this deployment allows ${cap} per run`,
|
||||
dimension,
|
||||
requested: cost[dimension],
|
||||
cap,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
if (!spend) {
|
||||
// A read of the meter rather than a draw on it. Deliberately advisory: this
|
||||
// answer is stale the moment another step in the same tick spends, which is
|
||||
// exactly why the authoritative check is the one that commits.
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const byDimension = new Map(rows.map((r) => [r.dimension, r]))
|
||||
for (const dimension of dimensions) {
|
||||
const held = byDimension.get(dimension)
|
||||
if (!held) return refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
if (held.cap !== null && held.consumed + cost[dimension] > held.cap) {
|
||||
return refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
// ── The committing path ──
|
||||
//
|
||||
// One statement per dimension, because the atomicity that matters is per
|
||||
// dimension: a cap is a bound on one thing, and a transaction spanning three of
|
||||
// them would serialise three unrelated counters to buy nothing. What it does
|
||||
// create is a partial spend — creatures taken, bosses refused — and a step that
|
||||
// did not run must not have spent anything, so the taken ones are given back.
|
||||
const taken = []
|
||||
for (const dimension of dimensions) {
|
||||
if (await budgetDb.spend(run.id, dimension, cost[dimension])) {
|
||||
taken.push(dimension)
|
||||
continue
|
||||
}
|
||||
for (const back of taken) await budgetDb.refund(run.id, back, cost[back])
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const held = rows.find((r) => r.dimension === dimension)
|
||||
return held
|
||||
? refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
: refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
}
|
||||
return { ok: true, cost, spent: true }
|
||||
}
|
||||
|
||||
/** The two cap refusals, written once so they cannot drift apart. */
|
||||
function refusal(dimension, requested, cap, consumed, code) {
|
||||
if (code === 'unbudgeted') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unbudgeted',
|
||||
reason: `spends "${dimension}", which this run has no budget for`,
|
||||
dimension,
|
||||
requested,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${requested} of "${dimension}"; ${consumed} of ${cap} is already spent this run`,
|
||||
dimension,
|
||||
requested,
|
||||
cap,
|
||||
consumed,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mayInvoke,
|
||||
isEnabled,
|
||||
priceOf,
|
||||
dimensionsOf,
|
||||
budgetsOf,
|
||||
undeclaredDimensions,
|
||||
effectiveCaps,
|
||||
changesWorld,
|
||||
WORLD_CHANGING,
|
||||
}
|
||||
437
server/src/events/cleanup.js
Normal file
437
server/src/events/cleanup.js
Normal file
@@ -0,0 +1,437 @@
|
||||
// ── Giving back what a run took ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §C ("Cleanup is generated, never authored"), §L and its two ledger
|
||||
// rules, and Phase 8 of EVENTS_PLAN.md. `events/ledger.js` is the write half;
|
||||
// this is the undo half, plus the reconcile that answers "is any of it still
|
||||
// there?" after something outside core restarted.
|
||||
//
|
||||
// **Cleanup is derived from the ledger, never authored.** An operator cannot be
|
||||
// relied on to write the undo, and an aborted run never reaches the phase they
|
||||
// wrote it in — so there is no cleanup phase in a spec and no `on_teardown` on an
|
||||
// action. There is one function, it reads rows, and it runs on EVERY terminal
|
||||
// path: completion, cancellation and abort alike.
|
||||
//
|
||||
// **It is not built out of `event_run_steps` rows** (org lead, 2026-09-03). The
|
||||
// plan's phrase is "cleanup steps are generated from the ledger", and the
|
||||
// tempting reading is a synthetic phase of real step rows so the console's
|
||||
// per-step retry comes free. It is the wrong shape here for one concrete reason:
|
||||
// `event_run_resources` already carries `revert_attempts` and `last_error`, so
|
||||
// synthetic steps would put a second retry counter beside the first and the two
|
||||
// would disagree the first time a step reverted three of its four resources.
|
||||
// The manual retry the API surface promises is a route over the ledger —
|
||||
// `POST /admin/events/runs/:runId/cleanup` — rather than a step control.
|
||||
//
|
||||
// **Where it runs from.** One place: the runner's cleanup leg, which finds
|
||||
// terminal runs that still owe the world something and works their rows. Hooking
|
||||
// each terminal path instead would be four call sites, three of which are inside
|
||||
// a request, and none of which would survive the process dying mid-cleanup. The
|
||||
// leg is ordered AFTER advance in the tick, so a run that completes in one tick
|
||||
// is cleaned in the same one.
|
||||
//
|
||||
// **The scan's WHERE clause cost two live-walk findings, in opposite
|
||||
// directions.** A run whose only resource was a LEASE never went through
|
||||
// `ledger.markRunDirty` — `core.lease` reserves its own row — so its
|
||||
// `cleanup_status` stayed `not_required` and the lease was never given back at
|
||||
// all. And a run whose first sweep failed was moved to `incomplete` by that very
|
||||
// sweep, so it was never picked up again: `MAX_REVERT_ATTEMPTS` meant ONE attempt
|
||||
// rather than three. The first is why `not_required` is in the scan; the second
|
||||
// is why `incomplete` is written HERE only once nothing retryable is left.
|
||||
//
|
||||
// **Rule 2 is what the bounds are for.** A revert that never succeeds must stay
|
||||
// visible rather than cycle: `MAX_REVERT_ATTEMPTS` stops the automatic retry, the
|
||||
// run reaches `completed` with `cleanup_status = 'incomplete'`, and the rows stay
|
||||
// on the console with their last error. Only a human's cleanup clears the
|
||||
// counter — Engagement Phase 14's rule, whose defect was a sweep that reset every
|
||||
// stale row and made the ceiling unreachable for ever.
|
||||
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const stepsDb = require('../model/events/eventRunSteps.db')
|
||||
const registries = require('../modules/registries')
|
||||
const { withDeadline } = require('./dispatch')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// How many times the automatic sweep will ask before leaving a resource for a
|
||||
// human. Three, like a step's, and for the same reason: a fourth attempt against
|
||||
// a shard that has answered the same way three times is not new information.
|
||||
const MAX_REVERT_ATTEMPTS = Number(process.env.EVENT_REVERT_MAX_ATTEMPTS) || 3
|
||||
|
||||
// The bound on one revert call, when the action that made the resource is gone
|
||||
// and there is no `budgetMs` to read. A restore is a round trip like any other.
|
||||
const DEFAULT_REVERT_BUDGET_MS = 10_000
|
||||
|
||||
// How many runs one cleanup leg looks at, and how many resource groups it works
|
||||
// per run. Bounds rather than targets, exactly like `RUN_BATCH`: the tick runs
|
||||
// again, and an unbounded teardown is how one run's bad night stalls every other.
|
||||
const CLEANUP_RUN_BATCH = Number(process.env.EVENT_CLEANUP_RUN_BATCH) || 10
|
||||
const CLEANUP_GROUPS_PER_RUN = Number(process.env.EVENT_CLEANUP_GROUPS_PER_RUN) || 25
|
||||
|
||||
/**
|
||||
* Classify one revert answer, with `dispatch.classify`'s posture: no shape a
|
||||
* failure can take may read as success.
|
||||
*
|
||||
* The extra value here is `drifted`. It is NOT an error — the module did exactly
|
||||
* what it was asked and found somebody else's value in place — so it is a third
|
||||
* outcome rather than a failure with a flag, and the row it produces is the one
|
||||
* §L wants surfaced beside the unreverted ones.
|
||||
*/
|
||||
function classifyRevert(raw, what) {
|
||||
if (raw && raw.__timedOut) return { outcome: 'retry', error: raw.error }
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { outcome: 'retry', error: `${what} answered with no envelope` }
|
||||
}
|
||||
if (raw.ok === true) {
|
||||
// §L, and the Rust wipe: "gone, and that is fine" is a successful revert. A
|
||||
// module never has to distinguish "I deleted it" from "it was not there".
|
||||
return { outcome: 'done', failed: Array.isArray(raw.failed) ? raw.failed.map(String) : [] }
|
||||
}
|
||||
if (raw.drifted === true) {
|
||||
return {
|
||||
outcome: 'drifted',
|
||||
error: `the value is now ${JSON.stringify(raw.current)} rather than what this run applied, so it was left alone`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: raw.retry === false ? 'terminal' : 'retry',
|
||||
error: raw.error ? String(raw.error) : `${what} refused`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a lease's `restore`, under a deadline, never throwing. */
|
||||
async function restoreLease(row) {
|
||||
// Through the ref parser, because a targeted lease's ref is `<id>#<target>`
|
||||
// (Phase 12b). A bare map lookup would miss every property lease and report
|
||||
// "no module registers" for one that is registered — leaving a spawner turned
|
||||
// up for good and blaming an uninstalled module for it.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
if (!lease) {
|
||||
// The module that owned it is uninstalled or failed to boot. Not a failure to
|
||||
// retry away — nothing will change until an operator reinstalls it — and not
|
||||
// an orphan either, because core has no idea whether the value is still
|
||||
// applied. It stays unresolved with the reason on it, which is exactly what
|
||||
// `cleanup_status = 'incomplete'` is for.
|
||||
return { outcome: 'terminal', error: `no module registers the lease "${row.ref}"` }
|
||||
}
|
||||
const payload = row.payload || {}
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() =>
|
||||
lease.restore(payload.baseline, {
|
||||
expected: payload.applied,
|
||||
runId: row.run_id,
|
||||
target: found.target,
|
||||
}),
|
||||
DEFAULT_REVERT_BUDGET_MS,
|
||||
row.ref,
|
||||
)
|
||||
} catch (err) {
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
return classifyRevert(raw, row.ref)
|
||||
}
|
||||
|
||||
/** Call an action's `revert` over a group of its rows, under a deadline, never throwing. */
|
||||
async function revertGroup(actionId, rows, idempotencyKey) {
|
||||
const action = registries.eventAction(actionId)
|
||||
if (!action || typeof action.revert !== 'function') {
|
||||
return {
|
||||
outcome: 'terminal',
|
||||
error: action
|
||||
? `${actionId} declares no revert()`
|
||||
: `no module registers "${actionId}", so its resources cannot be given back`,
|
||||
}
|
||||
}
|
||||
const payload = rows
|
||||
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
|
||||
.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null, memberKey: r.member_key || null }))
|
||||
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() => action.revert({ runId: rows[0].run_id, resources: payload, idempotencyKey }),
|
||||
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
|
||||
actionId,
|
||||
)
|
||||
} catch (err) {
|
||||
// A module should not throw from `revert` any more than from `perform`, and
|
||||
// one that does has produced a transient failure rather than a crashed sweep.
|
||||
log.warn('event revert threw', { action: actionId, message: err.message })
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
return classifyRevert(raw, actionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Work one run's ledger once.
|
||||
*
|
||||
* Answers what it found and what it managed, and sets `cleanup_status` from the
|
||||
* rows that are left rather than from what it did — the two differ whenever
|
||||
* another writer touched the run, and the rows are the truth.
|
||||
*
|
||||
* `resetAttempts` is the human's flag. It is never set by the automatic leg.
|
||||
*/
|
||||
async function cleanupRun(run, { resetAttempts = false, actor = null } = {}) {
|
||||
const summary = { attempted: 0, reverted: 0, drifted: 0, failed: 0, remaining: 0 }
|
||||
|
||||
if (resetAttempts) {
|
||||
const cleared = await resourcesDb.resetAttempts(run.id)
|
||||
await runsDb.setCleanupStatus(run.id, 'pending')
|
||||
if (cleared) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.retry',
|
||||
detail: { resources: cleared, by: actor },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await resourcesDb.unresolvedForRun(run.id, {
|
||||
maxAttempts: resetAttempts ? null : MAX_REVERT_ATTEMPTS,
|
||||
})
|
||||
|
||||
// **Grouped by the step that made them**, because that is what names the verb:
|
||||
// the resource row records the module and the opaque names, the step records
|
||||
// the action, and `revert()` takes a LIST so one round trip can give back
|
||||
// twelve creatures. A lease is its own group of one — core restores it through
|
||||
// the lease registry rather than through any action, which is the split §F
|
||||
// draws and the reason `core.lease` needs no `revert()` of its own.
|
||||
const leases = rows.filter((r) => r.kind === 'override')
|
||||
const byStep = new Map()
|
||||
for (const row of rows) {
|
||||
if (row.kind === 'override') continue
|
||||
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
|
||||
if (!byStep.has(key)) byStep.set(key, [])
|
||||
byStep.get(key).push(row)
|
||||
}
|
||||
|
||||
const groups = [...leases.map((r) => ({ lease: r })), ...[...byStep.values()].map((rs) => ({ rows: rs }))]
|
||||
|
||||
for (const group of groups.slice(0, CLEANUP_GROUPS_PER_RUN)) {
|
||||
if (group.lease) {
|
||||
const row = group.lease
|
||||
if (!(await resourcesDb.claimRevert(row.id))) continue
|
||||
summary.attempted += 1
|
||||
const verdict = await restoreLease(row)
|
||||
await applyVerdict(run, [row], verdict, summary, row.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
const rs = group.rows
|
||||
// The step is what names the action and carries the idempotency key. A row
|
||||
// whose step was deleted keeps the action id in its own payload, which is why
|
||||
// the placeholder writes one.
|
||||
const step = rs[0].step_id === null ? null : await stepsDb.getById(rs[0].step_id)
|
||||
const actionId = step?.action_id || rs[0].payload?.action || null
|
||||
if (!actionId) {
|
||||
await noteUnrevertable(run, rs, 'nothing records which action created this', summary)
|
||||
continue
|
||||
}
|
||||
const claimed = []
|
||||
for (const row of rs) if (await resourcesDb.claimRevert(row.id)) claimed.push(row)
|
||||
if (!claimed.length) continue
|
||||
summary.attempted += claimed.length
|
||||
const verdict = await revertGroup(actionId, claimed, step?.idempotency_key || rs[0].ref)
|
||||
await applyVerdict(run, claimed, verdict, summary, actionId)
|
||||
}
|
||||
|
||||
summary.remaining = await resourcesDb.unresolvedCount(run.id)
|
||||
// **`incomplete` means "finished with, and not finished"**, so it is written
|
||||
// only once there is nothing left this sweep will try. Writing it after the
|
||||
// FIRST failure — which is what the first draft did — took the run straight out
|
||||
// of the leg's own scan, and `MAX_REVERT_ATTEMPTS` quietly meant one attempt
|
||||
// rather than three. Found by the live walk, watching `revert_attempts` sit at
|
||||
// 1 through half a minute of ticks.
|
||||
const retryable = await resourcesDb.unresolvedForRun(run.id, { maxAttempts: MAX_REVERT_ATTEMPTS })
|
||||
const status = summary.remaining === 0 ? 'complete' : retryable.length ? 'pending' : 'incomplete'
|
||||
await runsDb.setCleanupStatus(run.id, status)
|
||||
|
||||
if (summary.attempted > 0) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.swept',
|
||||
detail: { ...summary, by: actor },
|
||||
})
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Write one verdict across the rows it covers, and count it. */
|
||||
async function applyVerdict(run, rows, verdict, summary, what) {
|
||||
for (const row of rows) {
|
||||
if (verdict.outcome === 'done' && !(verdict.failed || []).includes(row.ref)) {
|
||||
await resourcesDb.markReverted(row.id)
|
||||
summary.reverted += 1
|
||||
continue
|
||||
}
|
||||
if (verdict.outcome === 'drifted') {
|
||||
await resourcesDb.failRevert(row.id, verdict.error, 'drifted')
|
||||
summary.drifted += 1
|
||||
continue
|
||||
}
|
||||
const error =
|
||||
verdict.outcome === 'done'
|
||||
? `${what} could not give "${row.ref}" back`
|
||||
: verdict.error
|
||||
await resourcesDb.failRevert(row.id, error, 'confirmed')
|
||||
summary.failed += 1
|
||||
}
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: verdict.outcome === 'done' ? 'cleanup.reverted' : 'cleanup.failed',
|
||||
detail: {
|
||||
what,
|
||||
outcome: verdict.outcome,
|
||||
resources: rows.map((r) => `${r.kind}:${r.ref}`),
|
||||
...(verdict.error ? { error: verdict.error } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** A group core cannot even name a verb for. Counted as failed, and said once. */
|
||||
async function noteUnrevertable(run, rows, reason, summary) {
|
||||
for (const row of rows) {
|
||||
if (!(await resourcesDb.claimRevert(row.id))) continue
|
||||
await resourcesDb.failRevert(row.id, reason, 'confirmed')
|
||||
summary.attempted += 1
|
||||
summary.failed += 1
|
||||
}
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.failed',
|
||||
detail: { what: null, outcome: 'terminal', resources: rows.map((r) => `${r.kind}:${r.ref}`), error: reason },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The cleanup leg of the tick: every TERMINAL run with something left to give
|
||||
* back.
|
||||
*
|
||||
* Terminal only. A run still in flight has a ledger that is still growing, and
|
||||
* reverting a resource the next step is about to use would be core undoing an
|
||||
* event while it is happening.
|
||||
*/
|
||||
async function sweep() {
|
||||
// The ceiling goes INTO the query, so a run whose rows are all spent is not
|
||||
// selected, worked over and found to have nothing to do on every tick for the
|
||||
// rest of its life. It is also what excludes a run an admin cancelled without
|
||||
// cleanup, whose counters were spent deliberately.
|
||||
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
|
||||
let swept = 0
|
||||
for (const candidate of candidates) {
|
||||
if (!runsDb.TERMINAL.includes(candidate.status)) continue
|
||||
try {
|
||||
await cleanupRun(candidate)
|
||||
swept += 1
|
||||
} catch (err) {
|
||||
log.error('event cleanup failed', { run: candidate.id, message: err.message })
|
||||
}
|
||||
}
|
||||
return swept
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask one module which of its ledgered resources the game still has (§L, and
|
||||
* §N7's "the shard stays stateless about events").
|
||||
*
|
||||
* **Core cannot know when to ask**, and that is not an omission: §F says core has
|
||||
* no concept of the game being up, because a module with six sidecars cannot
|
||||
* answer that question in the singular. So the module triggers this, through
|
||||
* `ctx.events.reconcile()`, when it sees its own reconnect — module-uo already
|
||||
* watches `bootId` for exactly that. Core also asks once at boot, for its own
|
||||
* restart.
|
||||
*
|
||||
* **A resource the module no longer has becomes `orphaned`, never `reverted`.**
|
||||
* Reverting it would be core recording that it put something back when what
|
||||
* actually happened is that the thing vanished while nobody was looking, and the
|
||||
* two are different sentences to the operator reading the console afterwards.
|
||||
*
|
||||
* A module with no `reconcile()` on the action is not broken: core keeps
|
||||
* believing its own ledger, which is precisely the behaviour before this phase.
|
||||
*/
|
||||
async function reconcileModule(owner) {
|
||||
const rows = await resourcesDb.liveForModule(owner)
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
|
||||
if (!rows.length) return summary
|
||||
|
||||
const byStep = new Map()
|
||||
for (const row of rows) {
|
||||
if (row.kind === resourcesDb.STEP_KIND) continue // nothing to ask about yet
|
||||
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
|
||||
if (!byStep.has(key)) byStep.set(key, [])
|
||||
byStep.get(key).push(row)
|
||||
}
|
||||
|
||||
for (const group of byStep.values()) {
|
||||
const step = group[0].step_id === null ? null : await stepsDb.getById(group[0].step_id)
|
||||
const actionId = step?.action_id || group[0].payload?.action || null
|
||||
const action = actionId ? registries.eventAction(actionId) : null
|
||||
if (!action || typeof action.reconcile !== 'function') {
|
||||
summary.unanswered += group.length
|
||||
continue
|
||||
}
|
||||
summary.asked += group.length
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() =>
|
||||
action.reconcile({
|
||||
runId: group[0].run_id,
|
||||
resources: group.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null })),
|
||||
}),
|
||||
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
|
||||
actionId,
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('event reconcile threw', { action: actionId, message: err.message })
|
||||
raw = null
|
||||
}
|
||||
// Same posture as everywhere else: nothing that is not an explicit answer
|
||||
// counts as one. A module that could not answer leaves the ledger alone,
|
||||
// because "I do not know" must never be read as "it is gone".
|
||||
if (!raw || raw.__timedOut || raw.ok !== true || !Array.isArray(raw.inForce)) {
|
||||
summary.unanswered += group.length
|
||||
continue
|
||||
}
|
||||
const held = new Set(raw.inForce.map(String))
|
||||
for (const row of group) {
|
||||
if (held.has(row.ref)) {
|
||||
summary.inForce += 1
|
||||
continue
|
||||
}
|
||||
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
|
||||
summary.orphaned += 1
|
||||
await logDb.write({
|
||||
runId: row.run_id,
|
||||
kind: 'resource.orphaned',
|
||||
detail: { module: owner, resource: `${row.kind}:${row.ref}`, action: actionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Ask every module that owns a live row. Core's own boot-time sweep. */
|
||||
async function reconcileAll() {
|
||||
const owners = await resourcesDb.modulesWithLiveRows()
|
||||
const out = {}
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
out[owner] = await reconcileModule(owner)
|
||||
} catch (err) {
|
||||
log.error('event reconcile failed', { module: owner, message: err.message })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_REVERT_ATTEMPTS,
|
||||
classifyRevert,
|
||||
cleanupRun,
|
||||
sweep,
|
||||
reconcileModule,
|
||||
reconcileAll,
|
||||
}
|
||||
253
server/src/events/dispatch.js
Normal file
253
server/src/events/dispatch.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// ── Dispatching one step to one action ─────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §F. This is the boundary between the runner and code core did not
|
||||
// write, and it exists as its own file because it has exactly one job: call
|
||||
// `perform()` and turn whatever comes back — an envelope, a lie, a throw, a
|
||||
// promise that never settles — into one of four classifications the runner knows
|
||||
// how to act on.
|
||||
//
|
||||
// **§F's load-bearing rule, and the reason none of this is inlined into the
|
||||
// runner: no shape a failure can take may read as success.** A rejected promise,
|
||||
// a throw, a timeout, a non-object and a missing `ok` are all
|
||||
// `{ ok: false, retry: true }`. That is the inverse of `registerTeamProvider`'s
|
||||
// default, deliberately — a team provider that refuses leaves core showing what
|
||||
// it already had, because staleness is cheap, whereas an action that half-ran and
|
||||
// was recorded as `done` is a world change nothing will ever come back for.
|
||||
//
|
||||
// **The timeout is the module contract's, not this file's opinion.** Every action
|
||||
// declares `budgetMs` at registration and the registry bounds it there; here it
|
||||
// is enforced. Without it a module whose `perform()` awaits a socket that never
|
||||
// answers holds a step's claim until the lease expires, and the reclaim then
|
||||
// re-dispatches it — which is how one wedged sidecar becomes an infinite loop
|
||||
// rather than a failed step.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// What a classification can be. `parked` is Phase 2's addition and it is the one
|
||||
// outcome that is neither terminal nor a retry: the action succeeded, and the
|
||||
// step is not finished, because something outside this system has to happen next.
|
||||
const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
|
||||
|
||||
// The upper bound on `holdFor`, in seconds. A wait is a scheduling instruction,
|
||||
// not a lease, so this is generous — but it is bounded, because an action that
|
||||
// answers `holdFor: 1e9` would park the phase past the heat death of the shard
|
||||
// and the step that did it would look, in the console, exactly like one that
|
||||
// worked.
|
||||
const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
// The upper bound on a module's `detail`, in bytes of serialised JSON. It lands
|
||||
// in `event_run_log.detail` and is read back by the run console, so it is a
|
||||
// diagnostic line rather than a data channel — a module with more to say than
|
||||
// this has a table of its own to say it in. Dropped rather than truncated when it
|
||||
// is over: a truncated JSON object is not a JSON object, and a console that
|
||||
// rendered half of one would be a second bug on top of the first.
|
||||
const MAX_DETAIL_BYTES = 4096
|
||||
|
||||
/**
|
||||
* A module's own account of what a successful step actually did.
|
||||
*
|
||||
* Optional, module-opaque, and **never interpreted by core** — it is carried to
|
||||
* the run log and rendered, and nothing here or in the runner reads a key out of
|
||||
* it. That is the whole contract: a module knows things about its own verb that
|
||||
* core cannot compute and has no other way to say. `uo.item.grant` is the case
|
||||
* that forced it — a grant reaches the players a run's ledger holds, and *which
|
||||
* of them missed out* is knowable only to the module and reported nowhere else,
|
||||
* so an operator saw a step marked `done` and never learned four of twelve got
|
||||
* nothing.
|
||||
*
|
||||
* **Anything wrong with it is dropped and logged, never a failure.** A step that
|
||||
* did what it was asked must not be re-run because its module's commentary was
|
||||
* malformed — that would be a world write repeated for a log line. Same posture
|
||||
* `participants` takes, and for the same reason.
|
||||
*/
|
||||
function safeDetail(detail, actionId) {
|
||||
if (detail === undefined || detail === null) return null
|
||||
|
||||
// Objects only. The column is JSON and the console renders keys, so a bare
|
||||
// string or a number has nothing to render under — and core inventing a key to
|
||||
// put it beneath would be core interpreting it after all.
|
||||
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
||||
log.warn('action detail is not an object', { action: actionId, type: typeof detail })
|
||||
return null
|
||||
}
|
||||
|
||||
let encoded
|
||||
try {
|
||||
encoded = JSON.stringify(detail)
|
||||
} catch (err) {
|
||||
// A circular reference, or a `toJSON` that throws. Reaching the runner would
|
||||
// make the INSERT throw instead, inside the one write that is documented
|
||||
// never to.
|
||||
log.warn('action detail could not be serialised', { action: actionId, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (encoded === undefined) {
|
||||
log.warn('action detail serialised to nothing', { action: actionId })
|
||||
return null
|
||||
}
|
||||
if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) {
|
||||
log.warn('action detail is too large', {
|
||||
action: actionId,
|
||||
bytes: Buffer.byteLength(encoded, 'utf8'),
|
||||
max: MAX_DETAIL_BYTES,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Re-parsed rather than passed through, so what the runner writes is a plain
|
||||
// JSON value with no getters, no prototype and no live reference into whatever
|
||||
// the module still holds.
|
||||
return JSON.parse(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn()` under a deadline.
|
||||
*
|
||||
* The loser of the race is not cancelled — JavaScript has no such thing, and a
|
||||
* `perform()` still awaiting a socket keeps awaiting it. What the deadline buys
|
||||
* is that the RUNNER stops waiting, which is the half that matters: the step is
|
||||
* classified, the claim is released, and the tick moves on. A late answer from
|
||||
* the abandoned call lands on a step that has already been written, and the
|
||||
* idempotency key is what makes the retry that follows safe on the game side.
|
||||
*/
|
||||
function withDeadline(fn, ms, actionId) {
|
||||
let timer = null
|
||||
const deadline = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ __timedOut: true, error: `${actionId} exceeded its ${ms}ms budget` }),
|
||||
ms,
|
||||
)
|
||||
if (timer.unref) timer.unref()
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw `perform()` answer into
|
||||
* `{ outcome, error?, holdSeconds?, resources?, participants? }`.
|
||||
*
|
||||
* Exported and pure, so the classification rules are testable without a registry,
|
||||
* a database or a clock — which matters because they are the rules that decide
|
||||
* whether a world change is recorded as having happened.
|
||||
*/
|
||||
function classify(result, actionId) {
|
||||
if (result && result.__timedOut) {
|
||||
// Transient by default: a timeout says nothing about whether the action ran.
|
||||
// That ambiguity is exactly what the idempotency key exists to resolve, and
|
||||
// resolving it on the game side is Phase 11's protocol work — until then a
|
||||
// retry is the honest choice and the risk class decides what happens when the
|
||||
// retries run out.
|
||||
return { outcome: 'retry', error: result.error }
|
||||
}
|
||||
if (result === null || typeof result !== 'object' || Array.isArray(result)) {
|
||||
return { outcome: 'retry', error: `${actionId} answered with no envelope` }
|
||||
}
|
||||
if (result.ok !== true) {
|
||||
// `retry` must be opted into. An action that means "this will never work"
|
||||
// says `retry: false`, and an envelope that forgot to say anything gets the
|
||||
// benefit of the doubt on the transient question but not on the success one.
|
||||
const retry = result.retry !== false
|
||||
return {
|
||||
outcome: retry ? 'retry' : 'terminal',
|
||||
error: result.error ? String(result.error) : `${actionId} refused`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── The two success shapes that are not "finished" ──
|
||||
//
|
||||
// Both were settled by the org lead on 2026-09-02, and both are envelope
|
||||
// members rather than special cases keyed on an action id, so that the runner
|
||||
// never names a verb. `core.cue` and `core.wait` reach them through the same
|
||||
// door Phase 7 opens to a module's own long-running action.
|
||||
if (result.await === 'human') {
|
||||
return {
|
||||
outcome: 'parked',
|
||||
error: null,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
let holdSeconds = 0
|
||||
if (result.holdFor !== undefined && result.holdFor !== null) {
|
||||
const n = Number(result.holdFor)
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return { outcome: 'terminal', error: `${actionId} answered a bad holdFor "${result.holdFor}"` }
|
||||
}
|
||||
holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS)
|
||||
}
|
||||
|
||||
// `participants` rides beside `resources` and on the same two success shapes
|
||||
// (Phase 10). It is carried rather than interpreted here: what a member key
|
||||
// means is the module's business, and this file's whole job is to know
|
||||
// nothing about the verb it just called.
|
||||
return {
|
||||
outcome: 'done',
|
||||
error: null,
|
||||
holdSeconds,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
// On the same two success shapes as `resources` and `participants`, and for
|
||||
// the same reason: `await: 'human'` is a success, and a cue's confirm
|
||||
// finishes the step without a second dispatch, so this is the only moment
|
||||
// its module could ever have said anything.
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one step. Never throws.
|
||||
*
|
||||
* `verify` rides through to `perform()` unchanged (§I's dry run, Phase 6's
|
||||
* route): `verify === true` means validate and report, change nothing. It is
|
||||
* passed from here rather than being a separate code path so that the dry run
|
||||
* exercises the real dispatcher — a dry run down a second path is a dry run of
|
||||
* the second path.
|
||||
*/
|
||||
async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
|
||||
const action = registries.eventAction(step.action_id)
|
||||
if (!action) {
|
||||
// §L, verbatim: "a step naming one fails terminal with the module named, and
|
||||
// the run degrades rather than claiming success. Never a silent skip." The
|
||||
// module was uninstalled or failed to boot between publish and now — publish
|
||||
// refuses a dormant step, so this cannot be an authoring mistake.
|
||||
return { outcome: 'terminal', error: `no module registers "${step.action_id}"`, dormant: true }
|
||||
}
|
||||
|
||||
const envelope = {
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
idempotencyKey: step.idempotency_key,
|
||||
scope: run.scope || '',
|
||||
params: step.params || {},
|
||||
actor,
|
||||
verify: Boolean(verify),
|
||||
}
|
||||
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(() => action.perform(envelope), action.budgetMs, action.id)
|
||||
} catch (err) {
|
||||
// A module should not throw, and if one does it is a transient failure rather
|
||||
// than a crashed tick — announceWorker's posture with its legs, and the
|
||||
// reason one bad module cannot stop every other run on the deployment.
|
||||
log.warn('event action threw', { action: action.id, run: run.id, step: step.id, message: err.message })
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
|
||||
const classification = classify(raw, action.id)
|
||||
if (step.action_version && action.version !== step.action_version) {
|
||||
// Not a refusal: the step was authored against an older declaration and the
|
||||
// module has moved on. The editor is where that becomes a warning (§F); here
|
||||
// it is recorded, so a run that behaved oddly can be explained afterwards by
|
||||
// reading the log rather than by guessing.
|
||||
classification.actionVersionDrift = { authored: step.action_version, registered: action.version }
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }
|
||||
244
server/src/events/gates.js
Normal file
244
server/src/events/gates.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// ── Phase advance gates: the emit-path observer, and the words for the panel ─
|
||||
//
|
||||
// EVENTS.md §E and § Observability, and Phase 5 of EVENTS_PLAN.md. Two things
|
||||
// live here because they are two halves of one claim — that an operator can
|
||||
// answer *"why didn't phase 3 start?"* without reading a server log:
|
||||
//
|
||||
// `observe(event)` — the trigger stream's other subscriber. Beside
|
||||
// `engine.dispatch`, on the same seam, with the same
|
||||
// fire-and-forget posture.
|
||||
// `describe(gate)` — the same gate rendered in the condition builder's own
|
||||
// words, which is what the diagnosis panel shows.
|
||||
//
|
||||
// **Why the counting happens here and not on the runner's tick.** A gate that
|
||||
// waits for three boss spawns is counting things that happen *between* ticks. A
|
||||
// poller cannot count them: fifteen seconds after the third spawn there is
|
||||
// nothing left to observe, and a tally kept in a process's memory is a tally a
|
||||
// restart silently sets back to zero — with the phase then waiting for three
|
||||
// more of something that already happened. So the emit path writes, and the tick
|
||||
// reads. That division is the whole design of this file.
|
||||
//
|
||||
// **The cost of that, and its bound.** Every game event of every trigger some
|
||||
// run is waiting on costs one indexed lookup, and the common answer is zero
|
||||
// rows. Only when a gate is open does anything else happen, and then it is one
|
||||
// UPDATE per open gate — bounded by how many runs can be waiting on one trigger
|
||||
// at once, which is bounded by how many runs exist.
|
||||
//
|
||||
// **The words are rendered here, on the server, not in the client.** The panel's
|
||||
// entire value is that it reads the way the condition builder reads — `gte` as
|
||||
// *"is at least"*, `present` as *"is present"* — and those labels are defined in
|
||||
// `engagement/conditions.js`. A renderer in the browser would be a second
|
||||
// implementation of a grammar the server owns, and the first operator to meet a
|
||||
// clause it spelled differently would be the operator diagnosing a stalled run
|
||||
// at two in the morning.
|
||||
|
||||
const gatesDb = require('../model/events/eventPhaseGates.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const conditions = require('../engagement/conditions')
|
||||
const log = require('../utils/logger')('event-gates')
|
||||
|
||||
// How long an `on` gate may wait before the run is called `stalled` (§E's third
|
||||
// health value, which nothing had ever written before this phase). It is a
|
||||
// VISIBILITY threshold and not a timeout: nothing advances, nothing fails, and
|
||||
// the operator decides. An hour is long enough that a champion spawn nobody has
|
||||
// killed yet is not an alarm, and short enough that a run which will wait for
|
||||
// ever is on the screen inside one shift.
|
||||
//
|
||||
// It does not apply to an `after` gate. A phase waiting out six hours it was
|
||||
// authored to wait is not stalled, it is working, and health that said otherwise
|
||||
// would train an operator to ignore it.
|
||||
const STALL_MS = Number(process.env.EVENT_PHASE_STALL_MS) || 60 * 60 * 1000
|
||||
|
||||
/** Every variable a condition tree names, in the order it names them. */
|
||||
function variablesIn(node, out = []) {
|
||||
if (!node || typeof node !== 'object') return out
|
||||
if (Array.isArray(node.nodes)) {
|
||||
node.nodes.forEach((child) => variablesIn(child, out))
|
||||
return out
|
||||
}
|
||||
if (node.variable && !out.includes(node.variable)) out.push(node.variable)
|
||||
return out
|
||||
}
|
||||
|
||||
const quote = (v) => (typeof v === 'string' ? `"${v}"` : String(v))
|
||||
|
||||
/**
|
||||
* One condition tree as a sentence, using the grammar's own operator labels.
|
||||
*
|
||||
* `null` answers null rather than "always": the caller renders *"on any
|
||||
* `uo.champ.boss_up`"* for a gate with no predicate, and a phrase saying
|
||||
* "everything is true" would be a clause an operator has to read past.
|
||||
*/
|
||||
function phrase(node) {
|
||||
if (!node || typeof node !== 'object') return null
|
||||
if (node.op === 'not') {
|
||||
const inner = phrase((node.nodes || [])[0])
|
||||
return inner ? `not (${inner})` : null
|
||||
}
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const parts = (node.nodes || []).map(phrase).filter(Boolean)
|
||||
if (!parts.length) return null
|
||||
// Parenthesised only where it changes the reading. A flat `and` of three
|
||||
// comparisons is a sentence; the same three wrapped in brackets is a
|
||||
// diagnosis an operator has to parse rather than read.
|
||||
const joined = parts.map((p) => (p.includes(' or ') || p.includes(' and ') ? `(${p})` : p))
|
||||
return joined.join(node.op === 'and' ? ' and ' : ' or ')
|
||||
}
|
||||
const operator = conditions.OPERATORS[node.cmp]
|
||||
if (!operator) return null
|
||||
if (operator.arity === 0) return `${node.variable} ${operator.label}`
|
||||
if (operator.arity === 'list') return `${node.variable} ${operator.label} ${node.value.map(quote).join(', ')}`
|
||||
return `${node.variable} ${operator.label} ${quote(node.value)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the run console renders — a gate as an operator reads it.
|
||||
*
|
||||
* Derived rather than stored, every field of it. `stalled` in particular is a
|
||||
* comparison against the clock and not a column: a threshold that had been
|
||||
* written into a row at entry could not be changed by an operator raising
|
||||
* `EVENT_PHASE_STALL_MS`, and one written at the moment of stalling would be a
|
||||
* fourth writer on a row two already share.
|
||||
*/
|
||||
function describe(gate, now = new Date()) {
|
||||
if (!gate) return null
|
||||
const since = new Date(gate.entered_at)
|
||||
const satisfied = Boolean(gate.satisfied_at)
|
||||
// **A satisfied gate's clock stops when it was satisfied**, not at read time.
|
||||
// Live it answers "how long has this phase been waiting"; afterwards it
|
||||
// answers "how long did it wait", and those are the same number only while it
|
||||
// is still waiting. The walk caught it disagreeing with `phase.advanced`'s
|
||||
// own `waitedSeconds` by the age of the screen — 139s beside a logged 121.
|
||||
const until = satisfied ? new Date(gate.satisfied_at) : now
|
||||
const elapsedSeconds = Math.max(0, Math.round((until.getTime() - since.getTime()) / 1000))
|
||||
|
||||
return {
|
||||
phase: gate.phase,
|
||||
kind: gate.kind,
|
||||
satisfied,
|
||||
satisfiedAt: gate.satisfied_at || null,
|
||||
satisfiedBy: gate.satisfied_by || null,
|
||||
since: gate.entered_at,
|
||||
elapsedSeconds,
|
||||
// An `after` gate is never stalled; an `on` gate is stalled once it has
|
||||
// waited past the threshold and not before.
|
||||
stalled: !satisfied && gate.kind === 'on' && now.getTime() - since.getTime() >= STALL_MS,
|
||||
...(gate.kind === 'after'
|
||||
? { after: gate.after_seconds, dueAt: gate.due_at }
|
||||
: {
|
||||
waitingOn: gate.trigger_id,
|
||||
where: phrase(gate.conditions),
|
||||
seen: gate.tally,
|
||||
needed: gate.needed,
|
||||
lastEvent: gate.last_event,
|
||||
lastEventAt: gate.last_event_at || null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger stream's second subscriber.
|
||||
*
|
||||
* Called from `engagementEmit.emit` beside `engine.dispatch` and, like it, never
|
||||
* awaited and never allowed to reject. An emit is a module saying something
|
||||
* happened in the game; whether some event run cared is core's business, and a
|
||||
* failure of core's must not become the module's control flow.
|
||||
*
|
||||
* **Every firing is logged, matched or not** (§ Observability: "trigger
|
||||
* evaluations that did and did not satisfy a condition"). The near miss is the
|
||||
* more valuable of the two on the night: *"the boss did spawn, in Britain"* and
|
||||
* *"no boss has spawned"* are different answers, and without this line they look
|
||||
* identical on the screen.
|
||||
*/
|
||||
async function observe(event) {
|
||||
const summary = { gates: 0, counted: 0, satisfied: 0 }
|
||||
try {
|
||||
const open = await gatesDb.openForTrigger(event.triggerId)
|
||||
summary.gates = open.length
|
||||
if (!open.length) return summary
|
||||
|
||||
const now = new Date()
|
||||
for (const gate of open) {
|
||||
const matched = conditions.evaluate(gate.conditions, event.data || {})
|
||||
|
||||
// ONLY the variables the condition names, never the payload. This row is
|
||||
// read back onto an admin screen, and a copy of a whole game event's data
|
||||
// is a second copy of exactly the content `engagement_sends` is careful
|
||||
// not to keep. The named variables are also the useful ones: they are the
|
||||
// reason it did or did not count.
|
||||
const named = variablesIn(gate.conditions)
|
||||
const lastEvent = {
|
||||
trigger: event.triggerId,
|
||||
at: event.occurredAt,
|
||||
subject: event.subject ?? null,
|
||||
matched,
|
||||
variables: Object.fromEntries(
|
||||
named.filter((n) => event.data && n in event.data).map((n) => [n, event.data[n]]),
|
||||
),
|
||||
}
|
||||
|
||||
const result = matched
|
||||
? await gatesDb.count(gate.id, { lastEvent, now })
|
||||
: { counted: false, satisfied: false, tally: gate.tally, near: await gatesDb.noteNearMiss(gate.id, { lastEvent, now }) }
|
||||
|
||||
if (matched && result.counted) summary.counted += 1
|
||||
if (result.satisfied) summary.satisfied += 1
|
||||
|
||||
await logDb.write({
|
||||
runId: gate.run_id,
|
||||
kind: 'condition.evaluated',
|
||||
phase: gate.phase,
|
||||
detail: {
|
||||
trigger: event.triggerId,
|
||||
matched,
|
||||
// The tally the DATABASE holds after the write, not the one this
|
||||
// process predicted — two emits arriving together each read the same
|
||||
// stale number, and only one of them is right about what it became.
|
||||
seen: result.tally ?? gate.tally,
|
||||
needed: gate.needed,
|
||||
satisfied: result.satisfied,
|
||||
variables: lastEvent.variables,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.counted || summary.satisfied) {
|
||||
log.info('phase gate advanced', { trigger: event.triggerId, ...summary })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('gate observation failed', { trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this phase's gate open — and if it is not, why not?
|
||||
*
|
||||
* The runner's question, and the one place an `after` gate is closed: its
|
||||
* deadline passing is not an event anything emits, so the tick that finds it
|
||||
* past `due_at` is what records it. Doing that here rather than in the runner
|
||||
* keeps `satisfied_by` a fact one file writes.
|
||||
*
|
||||
* Answers `{ open, gate }`. `open: true` with a null gate is a phase with no
|
||||
* advance condition at all — every phase before this one, and most after it.
|
||||
*/
|
||||
async function check(runId, phase, now = new Date()) {
|
||||
const gate = await gatesDb.forPhase(runId, phase)
|
||||
if (!gate) return { open: true, gate: null }
|
||||
if (gate.satisfied_at) return { open: true, gate }
|
||||
|
||||
if (gate.kind === 'after' && gate.due_at && new Date(gate.due_at) <= now) {
|
||||
if (await gatesDb.satisfy(gate.id, 'elapsed', { now })) {
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
// Somebody else closed it between the read and the write — a force, or the
|
||||
// tick that overran into this one. Either way it is open, and whichever
|
||||
// reason won is the one in the row.
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
|
||||
return { open: false, gate }
|
||||
}
|
||||
|
||||
module.exports = { observe, check, describe, phrase, variablesIn, STALL_MS }
|
||||
224
server/src/events/ledger.js
Normal file
224
server/src/events/ledger.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// ── Recording what a run changed in the world ──────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §L, and Phase 8 of EVENTS_PLAN.md. The write half of the
|
||||
// resource ledger; `events/cleanup.js` is the read-and-undo half.
|
||||
//
|
||||
// **Rule 1 is the whole reason this file is not two lines inside `drainStep`.**
|
||||
// A resource is recorded BEFORE it is confirmed. The obstacle is that a spawn's
|
||||
// serial does not exist until the module answers, so there is nothing to write a
|
||||
// row about yet — which is why what goes in before the dispatch is a PLACEHOLDER
|
||||
// keyed by the step's idempotency key rather than by the object:
|
||||
//
|
||||
// pre-dispatch INSERT pending { kind: '@step', ref: <idempotency key> }
|
||||
// answer INSERT confirmed { kind: 'creature', ref: '0x40001234' } × n
|
||||
// resolve the placeholder
|
||||
// ack lost the placeholder is still `pending`
|
||||
// cleanup revert({ idempotencyKey, resources: [] })
|
||||
//
|
||||
// That last line is why §F's `revert({ runId, resources, idempotencyKey })` takes
|
||||
// the key at all. A module that half-ran and never answered is reachable by its
|
||||
// key and by nothing else, and Phase 11's plugin-side key ledger is what makes
|
||||
// answering it exact. Until then the contract is still honest, because §L
|
||||
// requires reverting something that does not exist to be a SUCCESS.
|
||||
//
|
||||
// **Recording is idempotent, and the database is what makes it so.** A retry
|
||||
// re-dispatches the same idempotency key, and a module that answers with the same
|
||||
// resources twice must not produce two rows. `uq_evres_target` refuses the second
|
||||
// insert, and this file reads that refusal as "already recorded" rather than as an
|
||||
// error — the same posture `materialisePhase`'s INSERT IGNORE takes.
|
||||
//
|
||||
// **A lease does not use the placeholder.** Its target is knowable before the
|
||||
// dispatch — it is the lease id the step names — so `core.lease` reserves the
|
||||
// real row first, which is both a stronger form of rule 1 and the only place the
|
||||
// two-events-one-target refusal can happen before the world has been written to.
|
||||
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// Which reversible classes get a pre-dispatch placeholder. `none` is gone once
|
||||
// done and `self` undoes itself, so neither has anything core could come back
|
||||
// for; `override` reserves its own target instead (see the header). That leaves
|
||||
// `ledger` — the class that declares `revert()`, which is exactly the class whose
|
||||
// refs core cannot know until the module speaks.
|
||||
const PLACEHOLDER_CLASSES = ['ledger']
|
||||
|
||||
// A resource `kind` may be anything a module likes except core's own reserved
|
||||
// one. Bounded to the column, and refused rather than truncated: a truncated ref
|
||||
// is a cleanup call naming the wrong object.
|
||||
const MAX_KIND = 64
|
||||
const MAX_REF = 190
|
||||
const MAX_MEMBER_KEY = 190
|
||||
|
||||
/** Does this action produce anything core will have to come back for? */
|
||||
const ledgers = (action) => action && (action.reversible === 'ledger' || action.reversible === 'override')
|
||||
|
||||
/**
|
||||
* Turn one entry of a module's `resources` array into a row, or say why not.
|
||||
*
|
||||
* Every failure here is the module's mistake rather than the world's, so none of
|
||||
* them is a retry: a badly shaped resource will be just as badly shaped on the
|
||||
* second attempt. They are logged and dropped, and the step still counts as done
|
||||
* — because it IS done; something happened in the world, and refusing to record
|
||||
* it would be the one outcome worse than recording it imperfectly.
|
||||
*/
|
||||
function normalise(entry, actionId) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, reason: `${actionId} reported a resource that is not an object` }
|
||||
}
|
||||
const kind = String(entry.kind || '')
|
||||
const ref = String(entry.ref === undefined || entry.ref === null ? '' : entry.ref)
|
||||
if (!kind || kind.length > MAX_KIND) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad kind "${entry.kind}"` }
|
||||
}
|
||||
if (kind === resourcesDb.STEP_KIND) {
|
||||
// Core's own. A module that could write one would be a module that could make
|
||||
// its own step's placeholder look resolved.
|
||||
return { ok: false, reason: `${actionId} reported a resource of the reserved kind "${kind}"` }
|
||||
}
|
||||
if (!ref || ref.length > MAX_REF) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad ref "${entry.ref}"` }
|
||||
}
|
||||
const memberKey = entry.memberKey === undefined || entry.memberKey === null ? null : String(entry.memberKey)
|
||||
if (memberKey !== null && memberKey.length > MAX_MEMBER_KEY) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with an over-long memberKey` }
|
||||
}
|
||||
|
||||
let leaseUntil = null
|
||||
if (entry.until !== undefined && entry.until !== null) {
|
||||
const at = new Date(entry.until)
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad until "${entry.until}"` }
|
||||
}
|
||||
leaseUntil = at
|
||||
}
|
||||
|
||||
// A borrowed value must name a lease core knows how to give back. Core restores
|
||||
// an `override` through the lease registry — that is the split §F draws — so a
|
||||
// ref naming nothing registered is a resource core would be recording with no
|
||||
// way to undo it, which is the promise rule 2 exists to stop core making.
|
||||
// Through the ref parser: a targeted lease's ref carries its target after a
|
||||
// `#` (Phase 12b), and matching the whole string against the registry would
|
||||
// reject a lease that IS registered.
|
||||
if (kind === 'override' && !registries.eventLeaseForRef(ref)) {
|
||||
return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
kind,
|
||||
ref,
|
||||
payload: entry.payload === undefined ? null : entry.payload,
|
||||
leaseUntil,
|
||||
memberKey,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the pre-dispatch placeholder for a step that is about to change the
|
||||
* world. Answers the row id, or null when this action ledgers nothing.
|
||||
*
|
||||
* **A duplicate is not a failure.** A retry re-uses its step's idempotency key, so
|
||||
* the second attempt's placeholder collides with the first's — and finding it
|
||||
* already there is the correct answer, not an error. The existing row is reused.
|
||||
*/
|
||||
async function reserveStep(run, step, action) {
|
||||
if (!ledgers(action) || !PLACEHOLDER_CLASSES.includes(action.reversible)) return null
|
||||
|
||||
const owner = action.owner || 'core'
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
owner,
|
||||
kind: resourcesDb.STEP_KIND,
|
||||
ref: step.idempotency_key,
|
||||
payload: { action: action.id, phase: step.phase, seq: step.seq },
|
||||
})
|
||||
if (reserved.ok) {
|
||||
await markRunDirty(run.id)
|
||||
return reserved.id
|
||||
}
|
||||
// The only way a '@step' row collides is with this step's own earlier attempt,
|
||||
// because an idempotency key is minted once per step and never varies by
|
||||
// attempt (§E). Reuse it.
|
||||
const existing = await resourcesDb.findByTarget(owner, resourcesDb.STEP_KIND, step.idempotency_key)
|
||||
return existing ? existing.id : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a module said it made, and close out the placeholder.
|
||||
*
|
||||
* Answers `{ recorded, rejected }` — how many rows went in, and the reasons any
|
||||
* entry was dropped. Never throws: a step that changed the world has changed it,
|
||||
* and a ledger that threw would turn a bookkeeping problem into a failed step and
|
||||
* then into a retry of a world write that already happened.
|
||||
*/
|
||||
async function recordAnswer({ run, step, action, placeholderId, resources }) {
|
||||
const out = { recorded: 0, rejected: [] }
|
||||
if (!ledgers(action)) return out
|
||||
|
||||
const owner = action.owner || 'core'
|
||||
const list = Array.isArray(resources) ? resources : []
|
||||
|
||||
for (const entry of list) {
|
||||
const parsed = normalise(entry, action.id)
|
||||
if (!parsed.ok) {
|
||||
out.rejected.push(parsed.reason)
|
||||
log.warn('event resource rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
owner,
|
||||
kind: parsed.row.kind,
|
||||
ref: parsed.row.ref,
|
||||
payload: parsed.row.payload,
|
||||
leaseUntil: parsed.row.leaseUntil,
|
||||
memberKey: parsed.row.memberKey,
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
// Already ledgered — by this step's own earlier attempt, or (a module bug
|
||||
// rather than a race) by another run that still holds the same target.
|
||||
// Either way there is a live row for it and a second would be the double
|
||||
// cleanup the unique key exists to prevent.
|
||||
if (reserved.holder && reserved.holder.run_id !== run.id) {
|
||||
out.rejected.push(`${parsed.row.kind} "${parsed.row.ref}" is already held by run ${reserved.holder.run_id}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
await resourcesDb.confirm(reserved.id)
|
||||
out.recorded += 1
|
||||
} catch (err) {
|
||||
// Bookkeeping must not become the step's control flow.
|
||||
out.rejected.push(err.message)
|
||||
log.error('event resource insert failed', { run: run.id, step: step.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (out.recorded > 0) await markRunDirty(run.id)
|
||||
|
||||
// The placeholder's job is over the moment the real rows exist. It is resolved
|
||||
// even when the module reported nothing at all — an action that ledgers and
|
||||
// then answers `ok` with an empty list is saying "I made nothing", and holding
|
||||
// its placeholder open would make cleanup call `revert()` for a step that has
|
||||
// nothing to give back on every terminal path for ever.
|
||||
if (placeholderId) await resourcesDb.resolvePlaceholder(placeholderId)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* There is now something to clean up. Idempotent and guarded, so it can never
|
||||
* walk a run back from `complete` or `incomplete` to `pending` — only a human's
|
||||
* cleanup does that, and it does it deliberately.
|
||||
*/
|
||||
async function markRunDirty(runId) {
|
||||
await runsDb.setCleanupStatus(runId, 'pending', ['not_required'])
|
||||
}
|
||||
|
||||
module.exports = { ledgers, normalise, reserveStep, recordAnswer, markRunDirty, PLACEHOLDER_CLASSES }
|
||||
162
server/src/events/participants.js
Normal file
162
server/src/events/participants.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// ── Recording who took part ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of
|
||||
// `events/ledger.js`: that file records what a run did to the world, this one
|
||||
// records who it happened to.
|
||||
//
|
||||
// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An
|
||||
// action answers `{ ok: true, participants: [...] }` and the runner writes them
|
||||
// beside the resources, on the same two success shapes, through the same
|
||||
// classify → record path. There is no `ctx.events.participants` API and no route:
|
||||
// a second write path into a run core is mid-tick on would be a second thing that
|
||||
// can race the claim, for a caller that does not exist until a module can source
|
||||
// the data at all (Phase 11's plugin-side participation ledger; Phase 12's
|
||||
// collect step is the first consumer).
|
||||
//
|
||||
// **Core cannot source a participant and does not try.** §J: `member_key` is
|
||||
// module-opaque, `user_id` is filled in by whoever knows the link table. For
|
||||
// module-uo that is `shard_links`; for another game it is something else, and a
|
||||
// core that guessed would be one game's identity model compiled into core. So a
|
||||
// module reports both halves, or reports the key alone and the row stays
|
||||
// anonymous — which is the honest record of an unlinked player who turned up.
|
||||
//
|
||||
// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s
|
||||
// posture and for exactly its reason: a malformed participant will be just as
|
||||
// malformed on the second attempt, and failing the step would re-dispatch a
|
||||
// world write that already happened. Rejections are logged and surfaced on the
|
||||
// run log so an author can see what their module sent.
|
||||
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// Bounded to the column, and refused rather than truncated: a truncated member
|
||||
// key is a different participant, and under `uq_evpart_member` it would silently
|
||||
// merge two people into one row.
|
||||
const MAX_MEMBER_KEY = 190
|
||||
|
||||
// The most one step may report. A run's participants are people, and a step
|
||||
// answering with a hundred thousand of them is a module bug rather than a very
|
||||
// popular event — one that would otherwise spend a tick's whole budget on
|
||||
// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement
|
||||
// engine is 5000 for the same class of reason and this matches it deliberately:
|
||||
// the two bound the same thing, a list of users one call may assert.
|
||||
const MAX_PER_STEP = 5000
|
||||
|
||||
/**
|
||||
* Turn one entry of a module's `participants` array into a row, or say why not.
|
||||
*/
|
||||
function normalise(entry, actionId) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant that is not an object` }
|
||||
}
|
||||
|
||||
const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey)
|
||||
if (!memberKey || memberKey.length > MAX_MEMBER_KEY) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` }
|
||||
}
|
||||
|
||||
// Optional, and checked rather than coerced. A `userId` is a foreign key into
|
||||
// `users`, so a module that passed a character serial here would either fail
|
||||
// the insert or — worse, if the number happened to be a real user — attribute
|
||||
// somebody else's attendance to a stranger.
|
||||
let userId = null
|
||||
if (entry.userId !== undefined && entry.userId !== null) {
|
||||
if (!Number.isInteger(entry.userId) || entry.userId < 1) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` }
|
||||
}
|
||||
userId = entry.userId
|
||||
}
|
||||
|
||||
// `score` is optional and defaults to 0 — a run that only records attendance
|
||||
// is a run where everybody scored nothing, which is a true statement and a
|
||||
// renderable table. Non-finite is refused rather than coerced: `NaN` written
|
||||
// into a DECIMAL would either throw at the driver or land as 0, and a 0 that
|
||||
// meant "the module sent nonsense" is indistinguishable from an honest zero.
|
||||
let score = 0
|
||||
if (entry.score !== undefined && entry.score !== null) {
|
||||
const n = Number(entry.score)
|
||||
if (!Number.isFinite(n)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` }
|
||||
}
|
||||
score = n
|
||||
}
|
||||
|
||||
let joinedAt = null
|
||||
if (entry.joinedAt !== undefined && entry.joinedAt !== null) {
|
||||
const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt)
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` }
|
||||
}
|
||||
joinedAt = at
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
memberKey,
|
||||
userId,
|
||||
score,
|
||||
// Opaque, exactly like a resource's payload: core stores it and never
|
||||
// reads it. Anything but an object is dropped rather than refused —
|
||||
// `meta` is decoration on a row whose identity is already valid, and
|
||||
// losing an event's whole attendance over a stray string would be the
|
||||
// wrong trade.
|
||||
meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null,
|
||||
joinedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a module said about who took part.
|
||||
*
|
||||
* Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a
|
||||
* step that changed the world has changed it, and a bookkeeping failure must not
|
||||
* become a retry of a world write.
|
||||
*/
|
||||
async function recordAnswer({ run, step, action, participants }) {
|
||||
const out = { recorded: 0, rejected: [] }
|
||||
const list = Array.isArray(participants) ? participants : []
|
||||
if (!list.length) return out
|
||||
|
||||
if (list.length > MAX_PER_STEP) {
|
||||
// Refused whole rather than truncated. Half a leaderboard silently cut at
|
||||
// five thousand is worse than none: the table would look complete and be
|
||||
// wrong, and nothing downstream could tell.
|
||||
const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may`
|
||||
log.warn('event participants refused', { run: run.id, step: step.id, reason })
|
||||
return { recorded: 0, rejected: [reason] }
|
||||
}
|
||||
|
||||
// **Deduplicated in memory before the write.** One step reporting the same
|
||||
// member twice is a module bug, and letting both reach the upsert would make
|
||||
// the LAST one win silently. Refusing the step would be worse — the other
|
||||
// ninety-nine participants are fine — so the first wins and the duplicate is
|
||||
// named, which is a thing an author can act on.
|
||||
const seen = new Set()
|
||||
|
||||
for (const entry of list) {
|
||||
const parsed = normalise(entry, action.id)
|
||||
if (!parsed.ok) {
|
||||
out.rejected.push(parsed.reason)
|
||||
log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||||
continue
|
||||
}
|
||||
if (seen.has(parsed.row.memberKey)) {
|
||||
out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`)
|
||||
continue
|
||||
}
|
||||
seen.add(parsed.row.memberKey)
|
||||
try {
|
||||
await participantsDb.record({ runId: run.id, ...parsed.row })
|
||||
out.recorded += 1
|
||||
} catch (err) {
|
||||
out.rejected.push(err.message)
|
||||
log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP }
|
||||
185
server/src/events/price.js
Normal file
185
server/src/events/price.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// ── The live cap meter ─────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I, Phase 13: the step editor's "live cap meter". What would this
|
||||
// plan spend, and what does this deployment allow?
|
||||
//
|
||||
// **It is not the dry run, and the difference is the whole reason it exists.**
|
||||
// `verify.js` dispatches every step with `verify: true` — through the module,
|
||||
// and through the module to a sidecar and a game tick — and a pass against a
|
||||
// published version is RECORDED, because that record is what §K's gate reads
|
||||
// before letting a schedule start something unattended. Both of those are right
|
||||
// for an act an author performs once, deliberately, when the plan is finished.
|
||||
// Neither is right for a number that has to move while somebody types: a meter
|
||||
// on the dry run's path would put a shard round trip behind every keystroke and
|
||||
// would stamp `verified_at` from a form still being edited.
|
||||
//
|
||||
// So this file answers the half of the question core can answer ON ITS OWN:
|
||||
// `cost()` is a pure function of params (§F), and the caps come from the
|
||||
// switchboard. Nothing is dispatched, nothing is written, and no definition need
|
||||
// exist — the body is the spec in the author's hands, saved or not.
|
||||
//
|
||||
// **What it therefore cannot tell you** is everything the module knows: whether
|
||||
// the landmark exists, whether the creature is on the allowlist, whether the
|
||||
// shard is reachable. That is the dry run's, and the meter must not read as a
|
||||
// substitute for it — which is why the editor keeps both and labels them apart.
|
||||
//
|
||||
// ## Why the per-phase subtotal is here rather than computed in the browser
|
||||
//
|
||||
// §I asks the timeline for "its cap draw" per phase, and the arithmetic is
|
||||
// trivial — but the *inputs* are not in the browser. `cost()` runs on the server
|
||||
// and only on the server; a client that summed anything would first have to be
|
||||
// handed per-step costs, which is this same call. Returning the phase rollup
|
||||
// beside the total costs one pass over a list core has already walked.
|
||||
|
||||
const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
const spec = require('./spec')
|
||||
|
||||
/**
|
||||
* Flatten `{ phases: [{ key, steps: [...] }] }` into the priceable steps.
|
||||
*
|
||||
* Bounded by the spec's own limits rather than by a number invented here: this
|
||||
* route takes an unsaved spec, so it is reachable with a body the save path
|
||||
* would refuse, and the paste guard has to be the same one.
|
||||
*/
|
||||
function flatten(body) {
|
||||
const phases = Array.isArray(body?.phases) ? body.phases : []
|
||||
if (phases.length > spec.MAX_PHASES) {
|
||||
return { ok: false, error: `at most ${spec.MAX_PHASES} phases` }
|
||||
}
|
||||
const flat = []
|
||||
for (const [index, phase] of phases.entries()) {
|
||||
const steps = Array.isArray(phase?.steps) ? phase.steps : []
|
||||
if (steps.length > spec.MAX_STEPS_PER_PHASE) {
|
||||
return { ok: false, error: `at most ${spec.MAX_STEPS_PER_PHASE} steps in one phase` }
|
||||
}
|
||||
for (const [seq, step] of steps.entries()) {
|
||||
flat.push({
|
||||
// The key is what the editor groups by, and an unsaved phase may not
|
||||
// have a valid one yet — so the ordinal is what is echoed back. A meter
|
||||
// that could only address a phase whose key already validates would go
|
||||
// blank exactly while somebody is naming it.
|
||||
phase: index,
|
||||
phaseKey: typeof phase?.key === 'string' ? phase.key : null,
|
||||
seq,
|
||||
actionId: typeof step?.actionId === 'string' ? step.actionId : '',
|
||||
params: step && typeof step.params === 'object' && !Array.isArray(step.params) ? step.params : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (flat.length > spec.MAX_STEPS) {
|
||||
return { ok: false, error: `at most ${spec.MAX_STEPS} steps in one definition` }
|
||||
}
|
||||
return { ok: true, flat }
|
||||
}
|
||||
|
||||
/**
|
||||
* Price a spec.
|
||||
*
|
||||
* **A step core cannot price is reported, never treated as free.** Three things
|
||||
* make one: no module registers the action, the action's `cost()` failed its own
|
||||
* contract (`priceOf` answers `null`), or it prices a dimension nobody declared.
|
||||
* All three make the totals below an UNDER-count, and a meter that silently
|
||||
* under-counts is worse than no meter — it is a number an author trusts that is
|
||||
* smaller than what will happen. So each one comes back in `unpriced` with the
|
||||
* step it belongs to, and the client shows the meter as incomplete.
|
||||
*
|
||||
* The third is not a refusal to price: an action that spends `uo.creatures`
|
||||
* spends it whether or not a module declared the dimension, so the amount is
|
||||
* still counted and the entry says the total is *unenforceable* rather than
|
||||
* unknown. Same split `authorize.undeclaredDimensions` makes for the same
|
||||
* reason.
|
||||
*/
|
||||
async function priceSpec(body) {
|
||||
const flattened = flatten(body)
|
||||
if (!flattened.ok) return { ok: false, error: flattened.error }
|
||||
const { flat } = flattened
|
||||
|
||||
const settings = await settingsDb.byIds(flat.map((s) => s.actionId))
|
||||
const totals = {}
|
||||
const byPhase = new Map()
|
||||
const unpriced = []
|
||||
let priced = 0
|
||||
|
||||
const addTo = (bag, dimension, amount) => {
|
||||
bag[dimension] = (bag[dimension] || 0) + amount
|
||||
}
|
||||
|
||||
for (const step of flat) {
|
||||
if (!byPhase.has(step.phase)) {
|
||||
byPhase.set(step.phase, { phase: step.phase, key: step.phaseKey, steps: 0, draw: {} })
|
||||
}
|
||||
const phase = byPhase.get(step.phase)
|
||||
phase.steps += 1
|
||||
|
||||
const where = { phase: step.phase, seq: step.seq, actionId: step.actionId || null }
|
||||
const action = step.actionId ? registries.eventAction(step.actionId) : null
|
||||
if (!action) {
|
||||
// A step with no action chosen yet is not a problem — it is a form being
|
||||
// filled in — so it is not reported. A step naming an action nothing
|
||||
// registers is, because that is the dormant case and it under-counts.
|
||||
if (step.actionId) {
|
||||
unpriced.push({ ...where, code: 'dormant', message: `no module registers "${step.actionId}"` })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const cost = authorize.priceOf(action, step.params)
|
||||
if (cost === null) {
|
||||
unpriced.push({ ...where, code: 'unpriceable', message: `"${action.label}" could not report what it costs` })
|
||||
continue
|
||||
}
|
||||
priced += 1
|
||||
for (const [dimension, amount] of Object.entries(cost)) {
|
||||
addTo(totals, dimension, amount)
|
||||
addTo(phase.draw, dimension, amount)
|
||||
}
|
||||
for (const dimension of authorize.undeclaredDimensions(cost)) {
|
||||
unpriced.push({
|
||||
...where,
|
||||
code: 'undeclared',
|
||||
message: `spends "${dimension}", which no installed module declares as a budget — this step is refused at dispatch`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const caps = authorize.effectiveCaps(
|
||||
flat.map((s) => ({ actionId: s.actionId, params: s.params })),
|
||||
settings,
|
||||
)
|
||||
|
||||
const cost = Object.entries(totals)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => {
|
||||
const cap = (caps[dimension] || {}).cap ?? null
|
||||
return {
|
||||
dimension,
|
||||
total,
|
||||
cap,
|
||||
from: (caps[dimension] || {}).from || null,
|
||||
over: cap !== null && total > cap,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
steps: flat.length,
|
||||
priced,
|
||||
cost,
|
||||
// Ordinal order, because that is timeline order and the client draws it
|
||||
// beside each phase. A phase whose steps price to nothing still appears, so
|
||||
// the rollup and the timeline have the same number of rows.
|
||||
phases: [...byPhase.values()].map((p) => ({
|
||||
phase: p.phase,
|
||||
key: p.key,
|
||||
steps: p.steps,
|
||||
draw: Object.entries(p.draw)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => ({ dimension, total })),
|
||||
})),
|
||||
unpriced,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { priceSpec }
|
||||
335
server/src/events/recurrence.js
Normal file
335
server/src/events/recurrence.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// ── Occurrence arithmetic, in the event's own timezone ─────────────────────
|
||||
//
|
||||
// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of
|
||||
// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window,
|
||||
// this file answers *which UTC instants* an event happens at. Nothing else
|
||||
// computes an occurrence; the runner materialises what this returns and the
|
||||
// calendar projects what this returns, so there is exactly one arithmetic to be
|
||||
// wrong.
|
||||
//
|
||||
// **Why there is no library here.** The server's dependency tree has no date
|
||||
// library at all — no luxon, no date-fns, no tz package (check `package.json`
|
||||
// before adding one). What it does have is Node's own full tzdata behind
|
||||
// `Intl.DateTimeFormat`, which is the same database a library would ship a copy
|
||||
// of and is already what `eventDefinitions.model.js` validates a zone name
|
||||
// against. So the arithmetic is: *format an instant into the zone's wall clock*
|
||||
// (which `Intl` does exactly) and invert that mapping by search. Everything
|
||||
// below is that one idea.
|
||||
//
|
||||
// **Why not cron.** Decided in §E and restated in the plan: there is no parser
|
||||
// in the tree, the only precedent is in the bot (another process), and a cron
|
||||
// string is the one field an operator cannot proofread. Four closed shapes
|
||||
// render as a form, and a form is checkable.
|
||||
//
|
||||
// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly
|
||||
// 02:30 event in `Europe/Berlin` is a real thing an operator will author:
|
||||
//
|
||||
// - A **nonexistent** local time — the spring-forward gap — steps forward to the
|
||||
// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event
|
||||
// happens as close to the authored time as the calendar allows.
|
||||
// - An **ambiguous** local time — the fall-back hour, which happens twice —
|
||||
// takes the FIRST, the pre-transition offset.
|
||||
//
|
||||
// Both are reported back as `adjusted`, so a run can record why its clock reads
|
||||
// oddly rather than leaving an operator to discover DST for themselves at 3am.
|
||||
// Neither rule ever drops an occurrence: a weekly event happens every week.
|
||||
|
||||
// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers
|
||||
// below return. Names rather than numbers everywhere an operator can see them —
|
||||
// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same
|
||||
// argument that rejected cron.
|
||||
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
|
||||
|
||||
// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a
|
||||
// month with five Fridays has a last Friday that is not the fourth. 1..4 always
|
||||
// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and
|
||||
// therefore no absent-occurrence case to define (org lead, 2026-09-02).
|
||||
const MONTHLY_NTH = [1, 2, 3, 4, -1]
|
||||
|
||||
const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest
|
||||
// historical jumps are a day, and those are line-of-date changes rather than
|
||||
// gaps in the local clock). Four hours is a bound, not an expectation: it stops
|
||||
// a malformed zone turning the search into a hang.
|
||||
const MAX_GAP_MINUTES = 240
|
||||
|
||||
// Bounds on what one call may return. A projection window is operator-supplied
|
||||
// (the calendar's month, the horizon), and an unbounded expansion of a daily
|
||||
// schedule across a decade is how a calendar request becomes an outage.
|
||||
const MAX_OCCURRENCES = 500
|
||||
|
||||
const formatters = new Map()
|
||||
|
||||
function formatterFor(zone) {
|
||||
let f = formatters.get(zone)
|
||||
if (!f) {
|
||||
// `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as
|
||||
// hour 24 in some ICU versions and would put every midnight event on the
|
||||
// previous day.
|
||||
f = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
formatters.set(zone, f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
/** The wall clock an instant reads as, in this zone. */
|
||||
function wallPartsAt(zone, ms) {
|
||||
const parts = formatterFor(zone).formatToParts(new Date(ms))
|
||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value)
|
||||
return {
|
||||
y: get('year'),
|
||||
m: get('month'),
|
||||
d: get('day'),
|
||||
h: get('hour'),
|
||||
mi: get('minute'),
|
||||
s: get('second'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* That same wall clock as a number, by reading it as though it were UTC.
|
||||
*
|
||||
* This is the trick the whole file rests on: two wall clocks are equal exactly
|
||||
* when these numbers are, and `wallMs - instant` is the zone's offset at that
|
||||
* instant. It is never a real instant and must not be used as one.
|
||||
*/
|
||||
function wallMs(zone, ms) {
|
||||
const p = wallPartsAt(zone, ms)
|
||||
return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s)
|
||||
}
|
||||
|
||||
const offsetMs = (zone, ms) => wallMs(zone, ms) - ms
|
||||
|
||||
/**
|
||||
* Every instant that reads as this wall clock in this zone, earliest first.
|
||||
*
|
||||
* Ordinarily one. Two in the fall-back hour, none in the spring-forward gap —
|
||||
* and the length of this array is how the caller tells those three apart.
|
||||
*
|
||||
* Sampling the offset a day either side is what makes it correct across a
|
||||
* transition: subtracting each candidate offset gives the two instants worth
|
||||
* testing, and the test is whether the instant formats back to what was asked.
|
||||
*/
|
||||
function instantsForWall(zone, target) {
|
||||
const candidates = new Set([
|
||||
target - offsetMs(zone, target - DAY_MS),
|
||||
target - offsetMs(zone, target + DAY_MS),
|
||||
])
|
||||
const valid = []
|
||||
for (const ms of candidates) {
|
||||
if (wallMs(zone, ms) === target) valid.push(ms)
|
||||
}
|
||||
return valid.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local wall clock to a UTC instant, applying the two DST rules.
|
||||
*
|
||||
* `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day,
|
||||
* `'gap'` when the authored time did not exist and was stepped forward, and
|
||||
* `'ambiguous'` when it happened twice and the first was taken.
|
||||
*/
|
||||
function resolveWall(zone, y, m, d, h, mi) {
|
||||
const target = Date.UTC(y, m - 1, d, h, mi, 0)
|
||||
const valid = instantsForWall(zone, target)
|
||||
if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 }
|
||||
if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 }
|
||||
|
||||
// The gap. Step the WALL CLOCK forward — not the instant — until it lands on
|
||||
// a time that exists, which is the first instant after the transition.
|
||||
for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) {
|
||||
const shifted = instantsForWall(zone, target + step * MINUTE_MS)
|
||||
if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── The civil calendar ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Dates with no zone attached: "the 14th of September" as a thing to iterate,
|
||||
// before any question of what instant it starts at. `Date.UTC` is used purely
|
||||
// as calendar arithmetic here and none of these numbers is an instant.
|
||||
|
||||
const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS
|
||||
|
||||
function civilFromIndex(n) {
|
||||
const dt = new Date(n * DAY_MS)
|
||||
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() }
|
||||
}
|
||||
|
||||
const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay()
|
||||
|
||||
const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate()
|
||||
|
||||
/** Is this a real date? `2026-02-30` parses as a string and is not a day. */
|
||||
const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)
|
||||
|
||||
/**
|
||||
* The day of the month that is the nth (or last) given weekday.
|
||||
*
|
||||
* `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which
|
||||
* the validated shapes never produce — the guard is here so that a spec written
|
||||
* by hand into the database cannot make the runner throw.
|
||||
*/
|
||||
function nthWeekdayDay(y, m, weekday, nth) {
|
||||
const last = daysInMonth(y, m)
|
||||
if (nth === -1) {
|
||||
const back = (weekdayOf(y, m, last) - weekday + 7) % 7
|
||||
return last - back
|
||||
}
|
||||
const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7
|
||||
const day = 1 + forward + (nth - 1) * 7
|
||||
return day <= last ? day : null
|
||||
}
|
||||
|
||||
// ── Expansion ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every occurrence of `schedule` in `[from, to)`, earliest first.
|
||||
*
|
||||
* `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the
|
||||
* shape that means "there is no recurrence", and an admin's own
|
||||
* `POST /:id/runs` is the only thing that creates one of its occurrences.
|
||||
*
|
||||
* The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each
|
||||
* walk starts a day early and ends a day late: a local day can begin up to
|
||||
* fourteen hours either side of the same UTC day.
|
||||
*/
|
||||
function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const toMs = to instanceof Date ? to.getTime() : Number(to)
|
||||
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
|
||||
if (!schedule || typeof schedule !== 'object') return []
|
||||
|
||||
const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES)
|
||||
const out = []
|
||||
const keep = (resolved) => {
|
||||
if (!resolved) return
|
||||
const t = resolved.at.getTime()
|
||||
if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved)
|
||||
}
|
||||
|
||||
if (schedule.kind === 'manual') return []
|
||||
|
||||
if (schedule.kind === 'once') {
|
||||
const m = AT_RE.exec(String(schedule.at || ''))
|
||||
if (!m) return []
|
||||
keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])))
|
||||
return out
|
||||
}
|
||||
|
||||
const time = TIME_RE.exec(String(schedule.time || ''))
|
||||
if (!time) return []
|
||||
const hour = Number(time[1])
|
||||
const minute = Number(time[2])
|
||||
|
||||
if (schedule.kind === 'weekly') {
|
||||
const wanted = new Set(
|
||||
(schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0),
|
||||
)
|
||||
if (!wanted.size) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
const startDay = dayIndex(first.y, first.m, first.d) - 1
|
||||
const endDay = dayIndex(last.y, last.m, last.d) + 1
|
||||
for (let n = startDay; n <= endDay && out.length < cap; n += 1) {
|
||||
const { y, m, d } = civilFromIndex(n)
|
||||
if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if (schedule.kind === 'monthly') {
|
||||
const weekday = WEEKDAYS.indexOf(String(schedule.weekday))
|
||||
const nth = Number(schedule.nth)
|
||||
if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
// Months as a single running count, so a window crossing a new year is not
|
||||
// a special case.
|
||||
const startMonth = first.y * 12 + (first.m - 1) - 1
|
||||
const endMonth = last.y * 12 + (last.m - 1) + 1
|
||||
for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) {
|
||||
const y = Math.floor(n / 12)
|
||||
const m = (n % 12) + 1
|
||||
const day = nthWeekdayDay(y, m, weekday, nth)
|
||||
if (day) keep(resolveWall(zone, y, m, day, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/** The next occurrence at or after `from`, or null. A bounded look-ahead. */
|
||||
function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, {
|
||||
limit: 1,
|
||||
})
|
||||
return first || null
|
||||
}
|
||||
|
||||
/**
|
||||
* How a schedule reads to a person, in the event's own zone.
|
||||
*
|
||||
* Server-side because two surfaces need the same sentence — the calendar's list
|
||||
* and the run's own record of why it exists — and because the client's copy in
|
||||
* `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on
|
||||
* meaning.
|
||||
*/
|
||||
function describe(schedule, zone = 'UTC') {
|
||||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||||
const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1)
|
||||
const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' }
|
||||
switch (schedule.kind) {
|
||||
case 'manual':
|
||||
return 'Started by hand'
|
||||
case 'once':
|
||||
return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})`
|
||||
case 'weekly': {
|
||||
const days = (schedule.days || []).map(cap)
|
||||
const list =
|
||||
days.length <= 1
|
||||
? days.join('')
|
||||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||||
return `Every ${list} at ${schedule.time} (${zone})`
|
||||
}
|
||||
case 'monthly':
|
||||
return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})`
|
||||
default:
|
||||
return 'No schedule'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTH,
|
||||
TIME_RE,
|
||||
AT_RE,
|
||||
MAX_OCCURRENCES,
|
||||
DAY_MS,
|
||||
wallPartsAt,
|
||||
offsetMs,
|
||||
instantsForWall,
|
||||
resolveWall,
|
||||
isRealDate,
|
||||
nthWeekdayDay,
|
||||
occurrencesBetween,
|
||||
nextOccurrence,
|
||||
describe,
|
||||
}
|
||||
622
server/src/events/spec.js
Normal file
622
server/src/events/spec.js
Normal file
@@ -0,0 +1,622 @@
|
||||
// ── The event spec, and the one place it is validated ──────────────────────
|
||||
//
|
||||
// EVENTS.md §C ("phases and actions are configuration inside a version snapshot,
|
||||
// not tables") and §D. A spec is the authored tree a definition carries and a
|
||||
// version freezes: phases, and the steps inside them. It is stored as JSON in
|
||||
// `event_definitions`' working copy and in `event_versions.spec`, and this file
|
||||
// is the only thing that decides whether one is well formed.
|
||||
//
|
||||
// **Every check here is a boundary, not a convenience.** The authoring UI (Phase
|
||||
// 3, then Phase 13) will re-implement some of them for the sake of a good
|
||||
// inline error, and that second copy is expected to drift — so this one is the
|
||||
// one that decides. A spec arriving by any other route (a restore, a fixture, a
|
||||
// module shipping a definition as content) gets the same answer.
|
||||
//
|
||||
// **What this file knows, and what it deliberately refuses.** Two top-level keys
|
||||
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
|
||||
// than preserved: a spec that silently carries `announcements` today is a spec
|
||||
// whose author believes announcements work, and the later phase that gives the
|
||||
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
|
||||
// `advance`, Phase 10 adds `announcements`.
|
||||
//
|
||||
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
||||
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
|
||||
// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
|
||||
// computed in is `event_definitions.timezone`, a sibling column this file cannot
|
||||
// see and does not need to: a well-formed wall clock resolves in every zone (a
|
||||
// DST gap shifts it, it is never rejected), so a schedule that validates here
|
||||
// computes there.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
|
||||
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
|
||||
// `authorize` requires two `.db.js` modules and requiring one opens no
|
||||
// connection, which is the same rule this file already lives under.
|
||||
const authorize = require('./authorize')
|
||||
const recurrence = require('./recurrence')
|
||||
const conditionGrammar = require('../engagement/conditions')
|
||||
const { checkLiteral } = conditionGrammar
|
||||
|
||||
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
|
||||
// run console groups by, and it is what an operator reads in "phase 3 has not
|
||||
// started". Same grammar as a template key's segment.
|
||||
const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
|
||||
const MAX_PHASE_KEY = 64
|
||||
|
||||
// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal
|
||||
// with a number in it rather than a run that materialises fifty thousand step
|
||||
// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side.
|
||||
const MAX_PHASES = 40
|
||||
const MAX_STEPS_PER_PHASE = 100
|
||||
const MAX_STEPS = 500
|
||||
|
||||
// The two shapes a phase's `advance` may take (§E). There is deliberately no
|
||||
// third: a gate that never opens is held, made visible and left to an operator
|
||||
// (org lead, 2026-09-02), so there is no authored timeout and no disposition to
|
||||
// validate. Adding one later is one key and one branch, and this is the list a
|
||||
// reader should find it missing from.
|
||||
const ADVANCE_KINDS = ['after', 'on']
|
||||
|
||||
// `after: '30m'` — one integer and one unit, and nothing else. No `1h30m`, no
|
||||
// fractions: the whole reason a duration is a string here rather than the plain
|
||||
// integer seconds `core.wait` takes is that an operator proofreads it, and a
|
||||
// grammar that admits two spellings of ninety minutes is one an operator has to
|
||||
// parse rather than read.
|
||||
const AFTER_RE = /^(\d{1,6})(s|m|h|d)$/
|
||||
const AFTER_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 }
|
||||
const MIN_AFTER_SECONDS = 1
|
||||
// A paste guard rather than a policy, in the spirit of MAX_PHASES: thirty days
|
||||
// is longer than any event this system is for, and a phase gate of ten years is
|
||||
// a typo that would otherwise hold a run — and its concurrency key — for ever.
|
||||
const MAX_AFTER_SECONDS = 30 * 86_400
|
||||
|
||||
// How many firings one `on` gate may wait for. Bounded for the reason MAX_LIST
|
||||
// is: it is authored into a JSON column, and "count: 100000" is a phase that
|
||||
// never advances written as one that eventually does.
|
||||
const MAX_ADVANCE_COUNT = 1000
|
||||
|
||||
// The four closed shapes of §E. `manual` is first because it is the default and
|
||||
// what an unscheduled draft carries; the other three are recurrences the runner
|
||||
// expands into occurrences ahead of time.
|
||||
const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
|
||||
|
||||
// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
|
||||
// an `at` is an author who believes something about it that is not true — the
|
||||
// same argument the top-level refusal makes, one level down.
|
||||
const SCHEDULE_KEYS = {
|
||||
manual: [],
|
||||
once: ['at'],
|
||||
weekly: ['days', 'time'],
|
||||
monthly: ['nth', 'weekday', 'time'],
|
||||
}
|
||||
|
||||
// What a step does when its attempts are exhausted (§L). The disposition only —
|
||||
// retry is not one of the values, it is what happens BEFORE one of them. Each
|
||||
// risk class has a default, which is the whole reason `risk` is required at
|
||||
// registration: a `change` action that fell back to `skip` would leave a run
|
||||
// advancing over a half-changed world.
|
||||
const ON_FAILURE = ['skip', 'pause', 'abort_run']
|
||||
const ON_FAILURE_BY_RISK = {
|
||||
notify: 'skip',
|
||||
inspect: 'skip',
|
||||
change: 'pause',
|
||||
irreversible: 'abort_run',
|
||||
}
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/** The default disposition for an action whose risk class core knows. */
|
||||
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
|
||||
|
||||
/**
|
||||
* Check one schedule shape and answer the normalised form of it.
|
||||
*
|
||||
* Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
|
||||
* one — because `validate` collects every error and carries on, and a caller
|
||||
* reading `spec.schedule.days` of a refused spec should find an empty recurrence
|
||||
* rather than a half-built one.
|
||||
*
|
||||
* **`days` is normalised into week order**, not into the order they were typed.
|
||||
* The spec is compared, described and diffed, and `['friday','monday']` and
|
||||
* `['monday','friday']` naming the same schedule while differing as JSON is a
|
||||
* version history that reports edits nobody made.
|
||||
*/
|
||||
function validateSchedule(kind, raw, errors) {
|
||||
const at = (key) => `spec.schedule.${key}`
|
||||
|
||||
if (kind === 'once') {
|
||||
const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
|
||||
if (!m) {
|
||||
errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
const [, y, mo, d, h, mi] = m.map(Number)
|
||||
// The regex admits `2026-02-30`, which is a string and not a day.
|
||||
if (!recurrence.isRealDate(y, mo, d)) {
|
||||
errors.push(`${at('at')}: "${raw.at}" is not a real date`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
// Stored as the operator wrote it — a wall clock in the definition's own
|
||||
// zone, never a UTC instant. §E: the schedule belongs to the event, and the
|
||||
// instant is derived at materialisation.
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
|
||||
}
|
||||
|
||||
if (kind === 'weekly' || kind === 'monthly') {
|
||||
const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
|
||||
if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
|
||||
|
||||
if (kind === 'weekly') {
|
||||
const rawDays = Array.isArray(raw.days) ? raw.days : null
|
||||
if (!rawDays || rawDays.length === 0) {
|
||||
errors.push(`${at('days')}: expected a non-empty array of weekday names`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
|
||||
if (unknown.length) {
|
||||
errors.push(
|
||||
`${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
const days = recurrence.WEEKDAYS.filter((name) =>
|
||||
rawDays.some((d) => String(d).toLowerCase() === name),
|
||||
)
|
||||
if (!time || !days.length) return { kind: 'manual' }
|
||||
return { kind: 'weekly', days, time }
|
||||
}
|
||||
|
||||
const weekday = String(raw.weekday ?? '').toLowerCase()
|
||||
if (!recurrence.WEEKDAYS.includes(weekday)) {
|
||||
errors.push(
|
||||
`${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
const nth = Number(raw.nth)
|
||||
if (!recurrence.MONTHLY_NTH.includes(nth)) {
|
||||
// -1 is "last", which a month with five Fridays makes different from 4.
|
||||
// There is no 5: every month has a first through fourth of every weekday,
|
||||
// so the closed set has no absent case (org lead, 2026-09-02).
|
||||
errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
|
||||
}
|
||||
if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
return { kind: 'monthly', nth, weekday, time }
|
||||
}
|
||||
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `'30m'` into seconds, or answer null.
|
||||
*
|
||||
* Exported because the run console renders the same duration back and must not
|
||||
* grow a second opinion about what `'2h'` means.
|
||||
*/
|
||||
function parseAfter(raw) {
|
||||
const m = AFTER_RE.exec(String(raw ?? ''))
|
||||
if (!m) return null
|
||||
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
|
||||
if (seconds < MIN_AFTER_SECONDS || seconds > MAX_AFTER_SECONDS) return null
|
||||
return seconds
|
||||
}
|
||||
|
||||
/** Seconds back to the largest whole unit that expresses them exactly. */
|
||||
function formatAfter(seconds) {
|
||||
for (const unit of ['d', 'h', 'm']) {
|
||||
const size = AFTER_UNIT_SECONDS[unit]
|
||||
if (seconds % size === 0) return `${seconds / size}${unit}`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a phase's `advance` gate and answer the normalised form of it.
|
||||
*
|
||||
* Returns `null` for a phase with no gate — the common case, and the behaviour
|
||||
* every phase had before Phase 5: it advances when its steps go terminal and on
|
||||
* nothing else. A gate is an ADDITIONAL condition, never a replacement, so a
|
||||
* phase whose steps are still running is not advanced by a satisfied gate.
|
||||
*
|
||||
* **The duration is normalised the way `days` is** — `'120m'` is stored as
|
||||
* `'2h'` — because the spec is diffed between versions, and two spellings of one
|
||||
* delay differing as JSON is a version history that reports edits nobody made.
|
||||
*
|
||||
* **`where` is validated against the trigger's DECLARATION, at save, with the
|
||||
* offending variable named.** This is the whole trap of this phase, and it is
|
||||
* `engagement/conditions.js`'s own argument one system across: a predicate that
|
||||
* silently reads `undefined` is a phase that silently never advances, and the
|
||||
* night you find out is the night of the event.
|
||||
*
|
||||
* **A trigger nobody registers makes the gate DORMANT, not invalid.** Same rule
|
||||
* as a step naming an action no installed module declares: it saves, so
|
||||
* uninstalling a module is not destructive to an author's work, and it refuses
|
||||
* to publish, because a version runs are pinned to must not wait on a trigger
|
||||
* that can never fire.
|
||||
*/
|
||||
function validateAdvance(raw, path, errors) {
|
||||
if (raw === undefined || raw === null) return null
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return null
|
||||
}
|
||||
|
||||
const keys = Object.keys(raw)
|
||||
const named = ADVANCE_KINDS.filter((k) => keys.includes(k))
|
||||
if (named.length !== 1) {
|
||||
errors.push(`${path}: expected exactly one of "after" or "on"`)
|
||||
return null
|
||||
}
|
||||
const kind = named[0]
|
||||
|
||||
if (kind === 'after') {
|
||||
const extra = keys.filter((k) => k !== 'after')
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "after" gate`)
|
||||
return null
|
||||
}
|
||||
const seconds = parseAfter(raw.after)
|
||||
if (seconds === null) {
|
||||
errors.push(
|
||||
`${path}.after: expected a duration like "30m" — a whole number of s, m, h or d, ` +
|
||||
`between ${MIN_AFTER_SECONDS}s and ${formatAfter(MAX_AFTER_SECONDS)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
// Only the canonical string is stored. The seconds are re-derived by the one
|
||||
// caller that needs them (the runner, when it opens the gate) through the
|
||||
// exported `parseAfter`, rather than kept beside it as a second field two
|
||||
// versions of the spec could disagree about.
|
||||
return { after: formatAfter(seconds) }
|
||||
}
|
||||
|
||||
// `dormant` is in this list for the reason `actionVersion` and `dormant` are
|
||||
// in a step's — **validate must accept its own output.** A saved spec is
|
||||
// re-validated on every later save and again at publish, so a field the
|
||||
// validator itself added and then refused would make the second save of any
|
||||
// gated definition impossible. It is accepted and then RECOMPUTED below,
|
||||
// never trusted: dormancy is whether anybody registers that trigger right
|
||||
// now, not what was true when the spec was last written.
|
||||
const extra = keys.filter((k) => !['on', 'where', 'count', 'dormant'].includes(k))
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "on" gate`)
|
||||
return null
|
||||
}
|
||||
|
||||
const triggerId = raw.on
|
||||
if (typeof triggerId !== 'string' || !triggerId) {
|
||||
errors.push(`${path}.on: expected a trigger id`)
|
||||
return null
|
||||
}
|
||||
|
||||
let count = 1
|
||||
if (raw.count !== undefined && raw.count !== null) {
|
||||
if (!Number.isInteger(raw.count) || raw.count < 1 || raw.count > MAX_ADVANCE_COUNT) {
|
||||
errors.push(`${path}.count: expected a whole number between 1 and ${MAX_ADVANCE_COUNT}`)
|
||||
return null
|
||||
}
|
||||
count = raw.count
|
||||
}
|
||||
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) {
|
||||
// Dormant, exactly as an unregistered action is. `where` is carried through
|
||||
// unvalidated and unnormalised — there is no declaration to check it
|
||||
// against, and dropping it would silently delete an author's predicate the
|
||||
// moment a module was uninstalled.
|
||||
return { on: triggerId, where: raw.where ?? null, count, dormant: true }
|
||||
}
|
||||
|
||||
const checked = conditionGrammar.validate(declaration, raw.where ?? null)
|
||||
if (!checked.ok) {
|
||||
// The grammar paths its own errors from the root token `conditions`; this
|
||||
// re-roots them at the phase so an author reading five of them at once can
|
||||
// tell which phase each belongs to. The text after the path — the part that
|
||||
// names the variable — is the grammar's, unchanged.
|
||||
checked.errors.forEach((e) =>
|
||||
errors.push(`${path}.where${e.startsWith('conditions') ? e.slice('conditions'.length) : `: ${e}`}`),
|
||||
)
|
||||
return null
|
||||
}
|
||||
return { on: triggerId, where: checked.conditions, count, dormant: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one authored param object against an action's declared params.
|
||||
*
|
||||
* Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent
|
||||
* drop: an author who typed `creatures` where the action declares `creature` has
|
||||
* written a step that would dispatch with the count missing, and dropping the key
|
||||
* makes that look like it saved cleanly.
|
||||
*/
|
||||
function checkParams(declaration, raw, path) {
|
||||
const errors = []
|
||||
const params = {}
|
||||
const given = isPlainObject(raw) ? raw : {}
|
||||
|
||||
if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
|
||||
return { params, errors: [`${path}.params: expected an object`] }
|
||||
}
|
||||
|
||||
const declared = new Map((declaration.params || []).map((p) => [p.name, p]))
|
||||
for (const name of Object.keys(given)) {
|
||||
if (!declared.has(name)) {
|
||||
errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of declaration.params || []) {
|
||||
const value = given[p.name]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
|
||||
continue
|
||||
}
|
||||
const checked = checkLiteral(p.type, value)
|
||||
if (checked.error) {
|
||||
errors.push(`${path}.params: "${p.name}" ${checked.error}`)
|
||||
continue
|
||||
}
|
||||
params[p.name] = checked.value
|
||||
}
|
||||
|
||||
return { params, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise a whole spec.
|
||||
*
|
||||
* `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }`
|
||||
* listing EVERY problem rather than the first — the posture `conditions.validate`
|
||||
* takes, and for the same reason: an author fixing one step at a time is an
|
||||
* author making six round trips through a form.
|
||||
*
|
||||
* `knownActionIds` widens what may be named beyond what is registered right now,
|
||||
* and it is how a definition survives its module being uninstalled. The rule,
|
||||
* lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger:
|
||||
* **a step already in the saved spec may keep an unregistered action; a new step
|
||||
* may not add one.** Refusing to save the whole definition would make an
|
||||
* uninstall destructive after the fact, and silently dropping the step would
|
||||
* delete authored work to make a form submit. A kept step is marked
|
||||
* `dormant: true` and its params pass through unvalidated, because the only
|
||||
* thing that could validate them left with the module.
|
||||
*/
|
||||
function validate(raw, { knownActionIds = [] } = {}) {
|
||||
const errors = []
|
||||
const known = new Set(knownActionIds)
|
||||
|
||||
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
|
||||
|
||||
const allowed = new Set(['schedule', 'phases'])
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowed.has(key)) {
|
||||
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── schedule ──
|
||||
const rawSchedule =
|
||||
raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
|
||||
let schedule = { kind: 'manual' }
|
||||
if (!isPlainObject(rawSchedule)) {
|
||||
errors.push('spec.schedule: expected an object')
|
||||
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
|
||||
errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
|
||||
} else {
|
||||
const kind = rawSchedule.kind
|
||||
const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
|
||||
const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
|
||||
if (extra.length) {
|
||||
errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
|
||||
}
|
||||
schedule = validateSchedule(kind, rawSchedule, errors)
|
||||
}
|
||||
|
||||
// ── phases ──
|
||||
const rawPhases = raw.phases
|
||||
const phases = []
|
||||
if (!Array.isArray(rawPhases) || rawPhases.length === 0) {
|
||||
errors.push('spec.phases: expected a non-empty array')
|
||||
return { ok: false, errors }
|
||||
}
|
||||
if (rawPhases.length > MAX_PHASES) {
|
||||
errors.push(`spec.phases: at most ${MAX_PHASES} phases`)
|
||||
return { ok: false, errors }
|
||||
}
|
||||
|
||||
const seenKeys = new Set()
|
||||
let totalSteps = 0
|
||||
|
||||
rawPhases.forEach((rawPhase, pi) => {
|
||||
const path = `spec.phases[${pi}]`
|
||||
if (!isPlainObject(rawPhase)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return
|
||||
}
|
||||
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps', 'advance'].includes(k))
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
|
||||
}
|
||||
|
||||
const advance = validateAdvance(rawPhase.advance, `${path}.advance`, errors)
|
||||
|
||||
const key = rawPhase.key
|
||||
if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
|
||||
errors.push(`${path}.key: bad phase key "${key}"`)
|
||||
} else if (seenKeys.has(key)) {
|
||||
// Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two
|
||||
// phases sharing a key would silently collapse into one at materialisation
|
||||
// and half the authored steps would never exist.
|
||||
errors.push(`${path}.key: "${key}" is used by more than one phase`)
|
||||
} else {
|
||||
seenKeys.add(key)
|
||||
}
|
||||
|
||||
if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`)
|
||||
|
||||
const rawSteps = rawPhase.steps
|
||||
if (!Array.isArray(rawSteps)) {
|
||||
errors.push(`${path}.steps: expected an array`)
|
||||
return
|
||||
}
|
||||
if (rawSteps.length > MAX_STEPS_PER_PHASE) {
|
||||
errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`)
|
||||
return
|
||||
}
|
||||
totalSteps += rawSteps.length
|
||||
|
||||
const steps = []
|
||||
rawSteps.forEach((rawStep, si) => {
|
||||
const spath = `${path}.steps[${si}]`
|
||||
if (!isPlainObject(rawStep)) {
|
||||
errors.push(`${spath}: expected an object`)
|
||||
return
|
||||
}
|
||||
// `actionVersion` and `dormant` are in this list because **validate must
|
||||
// accept its own output**. A saved spec is re-validated on every later
|
||||
// save and again at publish, so a normalised field that the validator
|
||||
// itself added and then refused would make the second save of any
|
||||
// definition impossible. They are accepted and then RECOMPUTED below
|
||||
// rather than trusted: the version comes from the declaration, and
|
||||
// dormancy from whether anyone registers the action right now.
|
||||
const stepExtra = Object.keys(rawStep).filter(
|
||||
(k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k),
|
||||
)
|
||||
if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`)
|
||||
|
||||
const actionId = rawStep.actionId
|
||||
const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null
|
||||
|
||||
if (typeof actionId !== 'string' || !actionId) {
|
||||
errors.push(`${spath}.actionId: a step needs an action`)
|
||||
return
|
||||
}
|
||||
if (!declaration && !known.has(actionId)) {
|
||||
errors.push(`${spath}.actionId: no module registers "${actionId}"`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!declaration) {
|
||||
// Dormant: kept verbatim, params untouched, and flagged so the editor and
|
||||
// the run console can both say WHY rather than showing an empty step.
|
||||
steps.push({
|
||||
actionId,
|
||||
label: rawStep.label || actionId,
|
||||
params: isPlainObject(rawStep.params) ? rawStep.params : {},
|
||||
actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1,
|
||||
onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause',
|
||||
dormant: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
|
||||
errors.push(...paramErrors)
|
||||
|
||||
// §F, fail closed (org lead, 2026-09-03): a step may not spend a dimension
|
||||
// no module declares as a budget. Refused HERE as well as at dispatch
|
||||
// because this is the cheap moment — the editor is open, the author is
|
||||
// looking at the step, and the alternative is a run that refuses at two in
|
||||
// the morning for a reason that was decidable when it was written.
|
||||
//
|
||||
// Only when the params validated. Pricing a step whose params were just
|
||||
// refused would run a module's `cost()` over values core has already said
|
||||
// are wrong, and report its answer as a second, confusing error about the
|
||||
// same mistake.
|
||||
if (!paramErrors.length) {
|
||||
const priced = authorize.priceOf(declaration, params)
|
||||
if (priced === null) {
|
||||
errors.push(`${spath}: "${declaration.label}" could not report what it costs`)
|
||||
} else {
|
||||
for (const dimension of authorize.undeclaredDimensions(priced)) {
|
||||
errors.push(
|
||||
`${spath}: "${declaration.label}" spends "${dimension}", which no module declares as a budget`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
|
||||
errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
|
||||
}
|
||||
|
||||
steps.push({
|
||||
actionId,
|
||||
label: rawStep.label || declaration.label,
|
||||
params,
|
||||
// Captured at SAVE time, from the declaration this step was authored
|
||||
// against (§F). It is what lets a later bump render a warning in the
|
||||
// editor instead of dispatching a mistyped parameter.
|
||||
actionVersion: declaration.version,
|
||||
onFailure: ON_FAILURE.includes(rawStep.onFailure)
|
||||
? rawStep.onFailure
|
||||
: defaultOnFailure(declaration.risk),
|
||||
dormant: false,
|
||||
})
|
||||
})
|
||||
|
||||
// `advance` is omitted rather than written as null when there is no gate:
|
||||
// the overwhelming majority of phases have none, and a spec full of
|
||||
// `"advance": null` is a diff between two versions that says something
|
||||
// changed about every phase the first time one phase gained a gate.
|
||||
phases.push(advance ? { key, label: rawPhase.label, steps, advance } : { key, label: rawPhase.label, steps })
|
||||
})
|
||||
|
||||
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, spec: { schedule, phases } }
|
||||
}
|
||||
|
||||
/** Every action id a spec names, dormant ones included. */
|
||||
const actionIdsIn = (spec) =>
|
||||
(spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean)
|
||||
|
||||
/**
|
||||
* A spec is publishable when nothing in it is dormant.
|
||||
*
|
||||
* Separate from `validate` on purpose: a dormant step must not stop an author
|
||||
* SAVING (that is what makes an uninstall non-destructive), and it must stop
|
||||
* them PUBLISHING, because publishing is what makes a version a thing runs are
|
||||
* pinned to and a run cannot dispatch a verb nobody registers.
|
||||
*
|
||||
* **A phase's advance gate is dormant on the same rule** (Phase 5), and it is in
|
||||
* the same list because it fails for the same reason and the message already
|
||||
* reads correctly for both: a version that waits on a trigger nothing can emit
|
||||
* is a run that would never leave that phase.
|
||||
*/
|
||||
function publishable(spec) {
|
||||
const phases = spec?.phases || []
|
||||
const dormant = [
|
||||
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
|
||||
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
|
||||
]
|
||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||
}
|
||||
|
||||
/** An empty, valid spec — what a newly created draft carries. */
|
||||
const emptySpec = () => ({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
publishable,
|
||||
actionIdsIn,
|
||||
emptySpec,
|
||||
defaultOnFailure,
|
||||
parseAfter,
|
||||
formatAfter,
|
||||
PHASE_KEY,
|
||||
SCHEDULE_KINDS,
|
||||
ADVANCE_KINDS,
|
||||
MAX_ADVANCE_COUNT,
|
||||
MAX_AFTER_SECONDS,
|
||||
ON_FAILURE,
|
||||
ON_FAILURE_BY_RISK,
|
||||
MAX_PHASES,
|
||||
MAX_STEPS_PER_PHASE,
|
||||
MAX_STEPS,
|
||||
}
|
||||
153
server/src/events/verify.js
Normal file
153
server/src/events/verify.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// ── The dry run ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I ("four affordances worth building in from the start") and §K's
|
||||
// last bound, in Phase 6. Materialise nothing, dispatch every step with
|
||||
// `verify: true`, and report what would happen and what it would cost.
|
||||
//
|
||||
// > **Dry run before anything unattended.** A scheduled definition that has never
|
||||
// > been verified is the case worth refusing to start; verification is cheap and
|
||||
// > it is the last point a human sees the plan.
|
||||
//
|
||||
// **What it verifies depends on the definition's state, and that is not a
|
||||
// compromise.** A `ready` definition is verified against its PUBLISHED VERSION,
|
||||
// because a published version is the only thing that ever actually runs and §K's
|
||||
// gate is about letting one run unattended. A draft is verified against its
|
||||
// working spec, because §API's note is explicit that an author prices their work
|
||||
// *before* asking an admin to publish it. The two readings do not conflict — they
|
||||
// are the same act at two moments — and the answer says which one it did.
|
||||
//
|
||||
// **Only a pass against a version is recorded.** A version is immutable, so a dry
|
||||
// run that passed against one stays true; a draft changes under the author's
|
||||
// hands, so a pass on it would be a claim about a spec that no longer exists.
|
||||
//
|
||||
// ## The finding that only exists here
|
||||
//
|
||||
// Every per-step check — is the action registered, is it enabled, does this one
|
||||
// invocation fit the cap — is a check something else also makes, at save or at
|
||||
// dispatch. **The TOTAL is not.** Three steps each spawning 15 creatures under a
|
||||
// cap of 30 pass every individual check and breach the cap on the third, at two
|
||||
// in the morning, with the world half-changed. Adding the costs up across the
|
||||
// whole version is the one thing that can only be done by looking at the plan as
|
||||
// a whole, and it is the reason a dry run is worth more than the sum of its
|
||||
// step checks.
|
||||
|
||||
const { dispatchStep } = require('./dispatch')
|
||||
const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
/**
|
||||
* Dry-run a spec.
|
||||
*
|
||||
* `user` is the caller, so the role layer answers for *them* — an editor gets
|
||||
* told that a step needs an administrator, at the moment they can still do
|
||||
* something about it, rather than at the moment it does not run.
|
||||
*
|
||||
* Never throws: a `perform()` that explodes under `verify: true` is a finding
|
||||
* about that action, not a 500 on the author's screen. `dispatchStep` already
|
||||
* guarantees that, and this file adds no path around it.
|
||||
*/
|
||||
async function verifySpec(spec, { user = null, scope = '' } = {}) {
|
||||
const phases = spec?.phases || []
|
||||
const flat = []
|
||||
for (const phase of phases) {
|
||||
for (const [seq, step] of (phase.steps || []).entries()) {
|
||||
flat.push({ phase: phase.key, seq, step })
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await settingsDb.byIds(flat.map(({ step }) => step.actionId))
|
||||
const findings = []
|
||||
const totals = {}
|
||||
|
||||
for (const { phase, seq, step } of flat) {
|
||||
const where = { phase, seq, actionId: step.actionId, label: step.label || null }
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) {
|
||||
// The same fact `publishable()` refuses on, said in the dry run's voice.
|
||||
// Reported rather than thrown so that an author sees EVERY problem in one
|
||||
// pass — a verification that stops at the first finding makes fixing a
|
||||
// twelve-step definition twelve round trips.
|
||||
findings.push({ ...where, level: 'error', code: 'dormant', message: `no module registers "${step.actionId}"` })
|
||||
continue
|
||||
}
|
||||
|
||||
const verdict = await authorize.mayInvoke({
|
||||
user,
|
||||
action,
|
||||
params: step.params || {},
|
||||
settings: settings.get(action.id) || null,
|
||||
})
|
||||
if (!verdict.ok) {
|
||||
findings.push({ ...where, level: 'error', code: verdict.code, message: verdict.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [dimension, amount] of Object.entries(verdict.cost || {})) {
|
||||
totals[dimension] = (totals[dimension] || 0) + amount
|
||||
}
|
||||
|
||||
// The module's own answer. This is the half core cannot compute: whether the
|
||||
// landmark exists, whether the creature is on the allowlist, whether the
|
||||
// shard is reachable at all. `verify: true` rides through the real
|
||||
// dispatcher rather than down a second path, because a dry run down a second
|
||||
// path is a dry run OF the second path.
|
||||
const result = await dispatchStep(
|
||||
{
|
||||
id: null,
|
||||
run_id: null,
|
||||
phase,
|
||||
seq,
|
||||
action_id: step.actionId,
|
||||
params: step.params || {},
|
||||
action_version: step.actionVersion || null,
|
||||
idempotency_key: null,
|
||||
attempts: 0,
|
||||
},
|
||||
{ run: { id: null, scope }, actor: user ? user.id : null, verify: true },
|
||||
)
|
||||
if (result.outcome === 'retry' || result.outcome === 'terminal') {
|
||||
findings.push({ ...where, level: 'error', code: 'refused', message: result.error })
|
||||
} else if (result.actionVersionDrift) {
|
||||
findings.push({
|
||||
...where,
|
||||
level: 'warning',
|
||||
code: 'version-drift',
|
||||
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── The whole-plan check ──
|
||||
const caps = authorize.effectiveCaps(
|
||||
flat.map(({ step }) => ({ actionId: step.actionId, params: step.params || {} })),
|
||||
settings,
|
||||
)
|
||||
const cost = Object.entries(totals)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => {
|
||||
const cap = (caps[dimension] || {}).cap ?? null
|
||||
const over = cap !== null && total > cap
|
||||
if (over) {
|
||||
findings.push({
|
||||
phase: null,
|
||||
seq: null,
|
||||
actionId: null,
|
||||
label: null,
|
||||
level: 'error',
|
||||
code: 'cap-total',
|
||||
message: `this event asks for ${total} of "${dimension}" across all its steps, and this deployment allows ${cap} per run`,
|
||||
})
|
||||
}
|
||||
return { dimension, total, cap, from: (caps[dimension] || {}).from || null, over }
|
||||
})
|
||||
|
||||
return {
|
||||
ok: !findings.some((f) => f.level === 'error'),
|
||||
steps: flat.length,
|
||||
findings,
|
||||
cost,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifySpec }
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, post_id, status, created_at, updated_at'
|
||||
const COLS = 'id, post_id, run_id, status, created_at, updated_at'
|
||||
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
||||
|
||||
async function legsFor(jobIds) {
|
||||
@@ -34,8 +34,15 @@ async function attachLegs(jobs) {
|
||||
|
||||
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
|
||||
// an empty list is legal and yields a job with nothing to deliver.
|
||||
async function create(postId, legs = []) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||
//
|
||||
// `runId` is Phase 10's (EVENTS.md §J): a job an EVENT asked for, rather than
|
||||
// the one a news publish enqueues. It changes nothing about how the job is
|
||||
// dispatched, retried or rolled up — the whole point of reusing this pipeline is
|
||||
// that an event announcement gets the legs, the backoff and the classification
|
||||
// already written — and everything it does change is in the two places below
|
||||
// that ask "whose job is this".
|
||||
async function create(postId, legs = [], { runId = null } = {}) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id, run_id) VALUES (?, ?)', [postId, runId])
|
||||
const jobId = Number(res.insertId)
|
||||
if (legs.length > 0) {
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
@@ -65,9 +72,15 @@ async function findById(id) {
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
// **The post's OWN job, which is what `run_id IS NULL` means here.** A post may
|
||||
// now have more than one — the news publish enqueued one, and an event linked the
|
||||
// same post later — and every caller of this function is the post admin panel or
|
||||
// its retry button, which are about the news announcement. Without the clause the
|
||||
// panel would silently start rendering an event's job the moment one existed, and
|
||||
// the retry button would retry that instead.
|
||||
async function findByPostId(postId) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? AND run_id IS NULL ORDER BY id DESC LIMIT 1`,
|
||||
[postId],
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
|
||||
@@ -45,6 +45,33 @@ async function enqueueIfNeeded(post, transition) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue an announcement a RUN asked for (EVENTS.md §J, Phase 10).
|
||||
*
|
||||
* The same job, the same legs, the same worker — so the town crier and Discord
|
||||
* come free, with their retry and their classification, rather than an event
|
||||
* growing a second delivery pipeline that would need both again and get them
|
||||
* subtly wrong. Two things differ, and both are about not standing on the news
|
||||
* pipeline's toes:
|
||||
*
|
||||
* **The post's back-pointer is written only when it has none.** `announce_job_id`
|
||||
* is what the post admin panel reads and what `shouldEnqueue` guards on, so
|
||||
* moving it to an event's job would make a re-published post announce itself
|
||||
* again. A post that has never been announced gains the pointer, because then
|
||||
* this job IS its announcement and the panel should show it.
|
||||
*
|
||||
* **`announced_at` is not stamped by a run's job** — see `refreshStatus`.
|
||||
*
|
||||
* Returns the new job id.
|
||||
*/
|
||||
async function enqueueForRun(postId, runId) {
|
||||
const jobId = await db.create(postId, registries.announceLegIds(), { runId })
|
||||
const post = await posts.getById(postId)
|
||||
if (post && !post.announce_job_id) await posts.linkAnnounceJob(postId, jobId)
|
||||
log.info('announce job enqueued for a run', { jobId, postId, runId })
|
||||
return jobId
|
||||
}
|
||||
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
|
||||
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
|
||||
// attempt count and schedule the next run (or fail the leg once the cap is hit).
|
||||
@@ -82,7 +109,12 @@ async function refreshStatus(jobId) {
|
||||
const status = logic.rollupStatus(job.legs.map((l) => l.status))
|
||||
if (status !== job.status) await db.setStatus(jobId, status)
|
||||
job.status = status
|
||||
if (status === 'done') {
|
||||
// **A run's job does not stamp the post** (Phase 10). `announced_at` means
|
||||
// "when this post was announced", and an event that links a three-week-old
|
||||
// news article would otherwise rewrite that to today — making the post admin
|
||||
// panel report a publication date it does not have. The event's own record of
|
||||
// having announced is the run log line and the job's `run_id`.
|
||||
if (status === 'done' && !job.run_id) {
|
||||
try {
|
||||
await posts.markAnnounced(job.post_id)
|
||||
} catch (err) {
|
||||
@@ -127,6 +159,7 @@ async function getByPostId(postId) {
|
||||
|
||||
module.exports = {
|
||||
enqueue,
|
||||
enqueueForRun,
|
||||
shouldEnqueue,
|
||||
enqueueIfNeeded,
|
||||
recordOutcome,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
|
||||
* cooling. ENGAGEMENT.md §4.1.
|
||||
* Claim a fire for (rule, user, subject, channel), or refuse it because that
|
||||
* delivery is still cooling. ENGAGEMENT.md §4.1.
|
||||
*
|
||||
* **`channel` is part of the key, and Phase 11b is where that was settled.** The
|
||||
* engine claims inside its per-channel loop, so a key without the channel means
|
||||
* the first channel of a two-channel rule claims the cooldown and every later one
|
||||
* is refused as cooling - which made every in-universe email body of Phase 11b
|
||||
* unreachable behind the in-app one. A cooldown is per delivery.
|
||||
*
|
||||
* **Two statements, each of which is its own atomic decision** - and it is worth
|
||||
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
|
||||
@@ -37,28 +43,29 @@ const { query } = require('../../utils/db')
|
||||
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
|
||||
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
|
||||
*/
|
||||
async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) {
|
||||
async function claim(ruleId, userId, subjectKey, channel, cooldownSeconds, now = new Date()) {
|
||||
const moved = await query(
|
||||
`UPDATE engagement_cooldowns
|
||||
SET last_fired_at = ?, fire_count = fire_count + 1
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND`,
|
||||
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
|
||||
[now, ruleId, userId, subjectKey, channel, now, cooldownSeconds],
|
||||
)
|
||||
if (Number(moved?.affectedRows || 0) === 1) return true
|
||||
|
||||
const inserted = await query(
|
||||
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, now],
|
||||
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, channel, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, channel, now],
|
||||
)
|
||||
return Number(inserted?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
const get = async (ruleId, userId, subjectKey) => {
|
||||
const get = async (ruleId, userId, subjectKey, channel) => {
|
||||
const [row] = await query(
|
||||
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
|
||||
[ruleId, userId, subjectKey],
|
||||
`SELECT * FROM engagement_cooldowns
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?`,
|
||||
[ruleId, userId, subjectKey, channel],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
@@ -71,8 +78,23 @@ const get = async (ruleId, userId, subjectKey) => {
|
||||
* failure `teamActivityPrune` was written for. A dropped row means the next fire
|
||||
* is treated as a first fire, which is correct as long as the retention window is
|
||||
* longer than the longest configured cooldown - the caller's job, not this one's.
|
||||
* Phase 14 gave it that caller (`engagementRetentionPrune`), which also enforces
|
||||
* the horizon-versus-longest-cooldown rule this comment names.
|
||||
*
|
||||
* `limit` bounds one statement, for `userNotificationsPrune`'s reason: a first
|
||||
* sweep after a long outage must be a series of bounded DELETEs rather than one
|
||||
* that holds locks over a million rows. Omitting it keeps the original
|
||||
* unbounded behaviour, so existing callers and tests are unaffected.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = (olderThan) =>
|
||||
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
|
||||
const prune = async (olderThan, limit = 0) => {
|
||||
const bounded = Number(limit) > 0
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_cooldowns WHERE last_fired_at < ?${bounded ? ' LIMIT ?' : ''}`,
|
||||
bounded ? [olderThan, Math.floor(limit)] : [olderThan],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { claim, get, prune }
|
||||
|
||||
@@ -132,11 +132,61 @@ async function cancel(ruleId, subjectKey, userId = null) {
|
||||
* stamped it), and the window has to be comfortably longer than the slowest
|
||||
* legitimate send or this reclaims rows that are merely slow.
|
||||
*/
|
||||
const reclaimStale = (before) =>
|
||||
query(
|
||||
const reclaimStale = async (before, maxAttempts = 0) => {
|
||||
// Give up first, reclaim second, and in that order: a row that has already
|
||||
// burned its attempts must leave 'sending' as `failed`, or the reclaim below
|
||||
// hands it straight back to `findDue` and it is retried forever.
|
||||
//
|
||||
// **This is what makes `pruneTerminal` a bound at all** (Phase 14). Attempts
|
||||
// are incremented by `claim`, but `MAX_ATTEMPTS` is only consulted on a
|
||||
// graceful `retry` outcome — a send that kills the process mid-flight never
|
||||
// reaches that branch, so before this the row cycled sending → scheduled →
|
||||
// sending forever, never reached a terminal status, and was therefore never
|
||||
// eligible for any retention sweep. One poisoned payload was an outbox row
|
||||
// that outlived every horizon.
|
||||
let failed = 0
|
||||
if (Number(maxAttempts) > 0) {
|
||||
const gaveUp = await query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = 'failed', last_error = 'gave up after repeated interruptions'
|
||||
WHERE status = 'sending' AND updated_at < ? AND attempts >= ?`,
|
||||
[before, Math.floor(maxAttempts)],
|
||||
)
|
||||
failed = Number(gaveUp?.affectedRows || 0)
|
||||
}
|
||||
const reclaimed = await query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
|
||||
[before],
|
||||
)
|
||||
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete terminal rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Terminal only, and the status list is the whole policy.** A `scheduled` row
|
||||
* is a promise the engine has not kept yet — `delay_seconds` can legitimately
|
||||
* put one up to a day out (`MAX_DELAY_SECONDS`) — and a `sending` row may be a
|
||||
* worker mid-flight. Deleting either is not retention, it is cancelling a send
|
||||
* nobody asked to cancel. Only `sent`, `failed`, `cancelled` and `suppressed`
|
||||
* are outcomes that have already happened.
|
||||
*
|
||||
* `created_at` rather than `updated_at` is the clock deliberately: the horizon
|
||||
* an operator sets means "how long we keep the record of a delivery", which is
|
||||
* measured from when it was enqueued, not from whenever it was last touched.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const pruneTerminal = async (before, limit = 1000) => {
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_outbox
|
||||
WHERE status IN ('sent', 'failed', 'cancelled', 'suppressed')
|
||||
AND created_at < ?
|
||||
LIMIT ?`,
|
||||
[before, Math.floor(limit)],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
|
||||
@@ -157,6 +207,7 @@ module.exports = {
|
||||
finish,
|
||||
cancel,
|
||||
reclaimStale,
|
||||
pruneTerminal,
|
||||
getById,
|
||||
listForRule,
|
||||
}
|
||||
|
||||
175
server/src/model/engagement/engagementRetention.model.js
Normal file
175
server/src/model/engagement/engagementRetention.model.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// ── Engagement retention policy ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. Three horizons, one place to read and write them, so
|
||||
// the nightly worker and Admin → Engagement → Retention cannot disagree about
|
||||
// what this deployment keeps — and so `/privacy` has one thing to describe.
|
||||
//
|
||||
// **The fourth engagement table is deliberately absent from this file.**
|
||||
// `engagement_suppressions` does not expire (org lead, 2026-09-01): a
|
||||
// suppression is a standing decision, and ageing out a hard bounce re-mails an
|
||||
// address that already bounced, which is how a sender loses a domain's
|
||||
// reputation. The one way out of that table stays what Phase 9 built — a
|
||||
// deliberate act by an admin, which Phase 14 only made reachable per row.
|
||||
//
|
||||
// Settings rows rather than env, for `teamActivityPrune`'s reason: an operator
|
||||
// tightening a busy shard should not need a deploy. Unlike the two workers this
|
||||
// copies, these three get a screen — the send log's horizon changes what an
|
||||
// operator-facing page can show, so it cannot be an invisible key.
|
||||
|
||||
const settings = require('../settings/settings.model')
|
||||
const rulesDb = require('./engagementRules.db')
|
||||
const log = require('../../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* One entry per horizon. `min` is not a UI nicety — each is the point below
|
||||
* which the sweep breaks something that is not retention:
|
||||
*
|
||||
* - **cooldowns**: a pruned row makes the next fire a FIRST fire, i.e. a
|
||||
* duplicate send. `MAX_COOLDOWN_SECONDS` is a validated 86 400 (one day), so
|
||||
* 2 days is the smallest provably-safe value against any rule that can be
|
||||
* saved. `checkCooldownHorizon` re-checks it against the rules that exist.
|
||||
* - **outbox**: `MAX_DELAY_SECONDS` is also 86 400, so no `scheduled` row is
|
||||
* ever more than a day out; terminal rows younger than that are still the
|
||||
* most recent thing an operator would look at.
|
||||
* - **sends**: the per-rule hourly ceiling (§7.1 Q3) counts this table, so a
|
||||
* horizon under an hour would silently disable it. The floor is set far
|
||||
* above that, at the point the Send Log stops being worth opening.
|
||||
*/
|
||||
const HORIZONS = {
|
||||
sends: {
|
||||
key: 'engagement_sends_retain_days',
|
||||
default: 180,
|
||||
min: 7,
|
||||
max: 3650,
|
||||
label: 'Send log',
|
||||
},
|
||||
cooldowns: {
|
||||
key: 'engagement_cooldowns_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Cooldowns',
|
||||
},
|
||||
outbox: {
|
||||
key: 'engagement_outbox_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Outbox',
|
||||
},
|
||||
}
|
||||
|
||||
const NAMES = Object.keys(HORIZONS)
|
||||
|
||||
/**
|
||||
* The current policy, as `{ sends, cooldowns, outbox }` in days.
|
||||
*
|
||||
* Wrapped in a try like `teamActivity.retentionConfig` and for its reason: the
|
||||
* nightly worker calls this with nobody watching, so a settings table that is
|
||||
* briefly unavailable must yield defaults rather than an exception that kills
|
||||
* the job. An unreadable, absent, non-numeric or out-of-range value all mean
|
||||
* the same thing — use the default — because none of them is a horizon.
|
||||
*/
|
||||
async function get() {
|
||||
const out = {}
|
||||
for (const name of NAMES) {
|
||||
const spec = HORIZONS[name]
|
||||
let days = spec.default
|
||||
try {
|
||||
const raw = await settings.get(spec.key)
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n >= spec.min && n <= spec.max) days = Math.floor(n)
|
||||
} catch (err) {
|
||||
log.debug('retention setting unreadable; using the default', {
|
||||
key: spec.key,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
out[name] = days
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one or more horizons. Unknown names are ignored rather than rejected, so
|
||||
* a client sending the whole object back is not coupled to this list; an
|
||||
* out-of-range value IS rejected, because silently clamping a number an operator
|
||||
* typed would leave the screen showing something the deployment is not doing.
|
||||
*
|
||||
* @returns {Promise<object>} the policy as it now stands
|
||||
*/
|
||||
async function set(patch = {}, updatedBy = null) {
|
||||
for (const name of NAMES) {
|
||||
if (!(name in patch) || patch[name] === undefined || patch[name] === null) continue
|
||||
const spec = HORIZONS[name]
|
||||
const n = Number(patch[name])
|
||||
if (!Number.isFinite(n) || Math.floor(n) !== n) {
|
||||
throw Object.assign(new Error(`${spec.label} retention must be a whole number of days`), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
if (n < spec.min || n > spec.max) {
|
||||
throw Object.assign(
|
||||
new Error(`${spec.label} retention must be between ${spec.min} and ${spec.max} days`),
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
await settings.set(spec.key, String(n), updatedBy)
|
||||
}
|
||||
return get()
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds — see
|
||||
* `engagementRules.db.maxEnabledCooldownSeconds` for why enabled only.
|
||||
*
|
||||
* Swallows its error rather than propagating: this is consulted by a worker
|
||||
* running on a timer, and a database hiccup must degrade the WARNING, never the
|
||||
* sweep. Zero reads as "no enabled rule has a cooldown", which produces no
|
||||
* warning — the same answer as an unreadable table, and the safe one, because
|
||||
* the alternative is a nightly alarm nobody can act on.
|
||||
*/
|
||||
async function longestEnabledCooldownSeconds() {
|
||||
try {
|
||||
return await rulesDb.maxEnabledCooldownSeconds()
|
||||
} catch (err) {
|
||||
log.debug('could not read the longest enabled cooldown', { message: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 14's acceptance line: the cooldown horizon is CHECKED against the
|
||||
* longest enabled rule's cooldown rather than picked.
|
||||
*
|
||||
* It returns a warning rather than throwing, and the worker sweeps anyway. The
|
||||
* alternative — refusing to prune — trades a bounded, describable fault (one
|
||||
* rule may re-fire early once) for the unbounded one this phase exists to end.
|
||||
* The screen surfaces the same warning, which is where an operator can act on it.
|
||||
*
|
||||
* @returns {Promise<{ ok: boolean, longestCooldownSeconds: number, message: string|null }>}
|
||||
*/
|
||||
async function checkCooldownHorizon(days) {
|
||||
const longest = await longestEnabledCooldownSeconds()
|
||||
const horizonSeconds = days * 24 * 60 * 60
|
||||
if (longest > 0 && horizonSeconds <= longest) {
|
||||
return {
|
||||
ok: false,
|
||||
longestCooldownSeconds: longest,
|
||||
message:
|
||||
`Cooldown retention is ${days} day(s), but an enabled rule has a cooldown of `
|
||||
+ `${longest} second(s). Pruning a cooldown row that is still in force makes the next `
|
||||
+ 'fire count as a first fire, so that rule can send twice. Raise the horizon.',
|
||||
}
|
||||
}
|
||||
return { ok: true, longestCooldownSeconds: longest, message: null }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HORIZONS,
|
||||
NAMES,
|
||||
get,
|
||||
set,
|
||||
longestEnabledCooldownSeconds,
|
||||
checkCooldownHorizon,
|
||||
}
|
||||
@@ -135,9 +135,25 @@ const countUsingSegment = async (segmentId) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds (Phase 14).
|
||||
*
|
||||
* Enabled only, deliberately: a disabled rule fires nothing, so it writes no
|
||||
* cooldown row a retention sweep could destroy, and letting a forgotten disabled
|
||||
* rule with a 24-hour cooldown veto a tighter horizon would make the warning
|
||||
* advice nobody can act on.
|
||||
*/
|
||||
const maxEnabledCooldownSeconds = async () => {
|
||||
const [row] = await query(
|
||||
'SELECT MAX(cooldown_seconds) AS n FROM engagement_rules WHERE enabled = 1',
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
maxEnabledCooldownSeconds,
|
||||
enabledForTrigger,
|
||||
enabledCancelledBy,
|
||||
insert,
|
||||
|
||||
@@ -40,6 +40,12 @@ const MAX_DELAY_SECONDS = 86_400
|
||||
// what makes rules-as-data safe (§7.1 Q3).
|
||||
const MAX_SENDS_PER_HOUR = 10_000
|
||||
|
||||
// The one key in `template_keys` that is not a delivery channel. `teamDigestWorker`
|
||||
// renders it for a rule whose email channel an individual has set to digest mode,
|
||||
// so it belongs to a MODE rather than to the rule's channel list and can never
|
||||
// appear there.
|
||||
const DIGEST_SLOT = 'digest'
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
@@ -91,7 +97,17 @@ async function validate(input, { existing = null } = {}) {
|
||||
errors.push('templateKeys must be an object of { channel: templateKey }')
|
||||
} else {
|
||||
for (const [channel, key] of Object.entries(raw.templateKeys || {})) {
|
||||
if (!wanted.includes(channel)) {
|
||||
// **`digest` is a template SLOT, not a channel**, and it is legal here for
|
||||
// exactly the reason `registries.js` `checkSeedRule` says it is: it names
|
||||
// the body `teamDigestWorker` renders for a rule whose email channel an
|
||||
// individual has set to digest mode, so it never appears in `channels` and
|
||||
// never could. Rejecting it made every rule that ships one unsaveable from
|
||||
// the Rules screen — core's own team and news rules included, and sixteen
|
||||
// of module-uo's — with a 400 naming a key the operator never typed, whose
|
||||
// only remedy was deleting the digest body and silently dropping digest
|
||||
// support. Found by Phase 13's acceptance walk; the two validators now
|
||||
// agree about what `digest` is.
|
||||
if (channel !== DIGEST_SLOT && !wanted.includes(channel)) {
|
||||
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -107,4 +107,26 @@ const count = async (opts = {}) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }
|
||||
/**
|
||||
* Delete send-log rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Every row here is terminal**, which is why this has no status filter and the
|
||||
* outbox's sweep does: `engagement_sends` records an attempt that has already
|
||||
* resolved. The care is entirely in the horizon, because this table has two live
|
||||
* readers and they pull in opposite directions — `countSentSince` implements the
|
||||
* per-rule hourly ceiling (§7.1 Q3), so any horizon under an hour silently
|
||||
* disables that ceiling, and Admin -> Engagement -> Send Log is the operator's
|
||||
* only answer to "was this person told", so a short one blinds it. Both are the
|
||||
* caller's problem, and `engagementRetention` is where that judgement lives.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = async (before, limit = 1000) => {
|
||||
const result = await query('DELETE FROM engagement_sends WHERE created_at < ? LIMIT ?', [
|
||||
before,
|
||||
Math.floor(limit),
|
||||
])
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, prune, TEST_SEND_TRIGGER }
|
||||
|
||||
86
server/src/model/events/eventActionSettings.db.js
Normal file
86
server/src/model/events/eventActionSettings.db.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── event_action_settings — SQL only ───────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard:
|
||||
// one row per action an admin has an opinion about, and **nothing else in the
|
||||
// permission model beyond the role**.
|
||||
//
|
||||
// **A missing row is not "disabled".** It is "the default for this action's risk
|
||||
// class", and that default is computed in `eventActionSettings.model.js` from the
|
||||
// registry rather than stored here. The reason is structural: the registry is
|
||||
// assembled by `registerCore()` and by module `register()`, both of which run
|
||||
// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md
|
||||
// §2.2), so a boot-time seed of one row per registered action would be precisely
|
||||
// the database write those two forbid. A deployment that never opens the
|
||||
// switchboard has no rows at all and behaves correctly.
|
||||
//
|
||||
// **Rows outlive their actions on purpose.** Uninstalling a module leaves its
|
||||
// settings standing, so re-installing restores the caps the operator chose
|
||||
// instead of silently resetting them to the default. The switchboard lists what
|
||||
// is registered *now*, so a stranded row is invisible until its action returns.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} }
|
||||
|
||||
/** Every stored row, action id first. Stranded rows included — the caller filters. */
|
||||
async function all() {
|
||||
const rows = await query(
|
||||
`SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username
|
||||
FROM event_action_settings s
|
||||
LEFT JOIN users u ON u.id = s.updated_by
|
||||
ORDER BY s.action_id`,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** One row, or null when the deployment has never had an opinion about this action. */
|
||||
async function get(actionId) {
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings WHERE action_id = ?`,
|
||||
[actionId],
|
||||
)
|
||||
return rows.length ? hydrate(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for a set of action ids, as a Map keyed by id.
|
||||
*
|
||||
* The shape the authorisation path wants: `mayInvoke` is asked about one action
|
||||
* at a time but the run start prices a whole version at once, and one query per
|
||||
* step of a twelve-step definition is twelve round trips to answer a question
|
||||
* about a table with one row per action in the process.
|
||||
*/
|
||||
async function byIds(actionIds) {
|
||||
const ids = [...new Set(actionIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings
|
||||
WHERE action_id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
return new Map(rows.map((r) => [r.action_id, hydrate(r)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one action's switch and caps.
|
||||
*
|
||||
* An upsert rather than a read-then-write, for the ordinary reason: two admins on
|
||||
* the switchboard at once should leave one of their two opinions standing, not an
|
||||
* error and not a row that never appeared. There is no compare-and-set here
|
||||
* because there is nothing to race — this is configuration, and the value that
|
||||
* matters is the last one a human chose.
|
||||
*/
|
||||
async function put(actionId, { enabled, caps }, userId = null) {
|
||||
await query(
|
||||
`INSERT INTO event_action_settings (action_id, enabled, caps, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`,
|
||||
[actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId],
|
||||
)
|
||||
return get(actionId)
|
||||
}
|
||||
|
||||
module.exports = { all, get, byIds, put }
|
||||
170
server/src/model/events/eventCalendar.model.js
Normal file
170
server/src/model/events/eventCalendar.model.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// ── The calendar ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I: "month and list view, filtered by category, scope and series",
|
||||
// and Phase 4's stated deliverable — *the thing this feature exists to replace*
|
||||
// is a WordPress calendar plugin with no series field and no recurrence.
|
||||
//
|
||||
// **A calendar entry is one of two things, and the difference is not cosmetic.**
|
||||
//
|
||||
// - A **run**: a real `event_runs` row. It has an id, a status, a health, a
|
||||
// pinned version and a console. Somebody can cancel it. It exists because the
|
||||
// runner materialised it inside its fourteen-day horizon, or because an admin
|
||||
// started it by hand.
|
||||
// - A **projection**: arithmetic. There is no row, nothing to cancel, and
|
||||
// nothing has been committed to. It exists so that a monthly event is visible
|
||||
// three weeks out instead of the calendar simply ending at the horizon (org
|
||||
// lead, 2026-09-02).
|
||||
//
|
||||
// The API says which each is and the UI renders them differently, because an
|
||||
// operator acting on a projection as though it were a booking is the failure
|
||||
// this distinction exists to prevent. A projection is a forecast of what the
|
||||
// runner *will* materialise, computed by the same `occurrencesBetween` the
|
||||
// runner itself calls — one arithmetic, so the forecast cannot disagree with
|
||||
// what later appears.
|
||||
//
|
||||
// **A projection is never emitted for an instant a run already occupies**, which
|
||||
// is what keeps the fortnight inside the horizon from showing everything twice.
|
||||
// That rule also does the right thing for a CANCELLED occurrence: the row is
|
||||
// still there, so nothing re-projects it, and an event an operator called off
|
||||
// does not reappear on the calendar as though it were still coming.
|
||||
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
|
||||
// A calendar request is operator-supplied, and a year-wide window across forty
|
||||
// weekly definitions is how a month view becomes an outage. Ninety-two days is
|
||||
// a three-month view — more than the month grid and the list either need.
|
||||
const MAX_WINDOW_DAYS = 92
|
||||
const MAX_ENTRIES = 1000
|
||||
|
||||
const runEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
definitionId: run.definition_id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesId: run.series_id || null,
|
||||
seriesName: run.series_name || null,
|
||||
seriesSlug: run.series_slug || null,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
scope: run.scope,
|
||||
status: run.status,
|
||||
health: run.health,
|
||||
version: run.version_number,
|
||||
rehearsal: Boolean(run.rehearsal),
|
||||
waitingSteps: Number(run.waiting_steps || 0),
|
||||
})
|
||||
|
||||
const projectedEntry = (definition, occurrence) => ({
|
||||
kind: 'projected',
|
||||
runId: null,
|
||||
definitionId: definition.id,
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
seriesId: definition.series_id || null,
|
||||
seriesName: definition.series_name || null,
|
||||
seriesSlug: definition.series_slug || null,
|
||||
scheduledFor: occurrence.at,
|
||||
timezone: definition.timezone,
|
||||
scope: '',
|
||||
status: null,
|
||||
health: null,
|
||||
// Why this instant is not the wall clock the schedule names. Carried on the
|
||||
// projection as well as on the materialised run, so the calendar can explain
|
||||
// a DST-shifted time before it happens rather than after.
|
||||
adjusted: occurrence.adjusted,
|
||||
shiftMinutes: occurrence.shiftMinutes,
|
||||
})
|
||||
|
||||
/**
|
||||
* The calendar for a window.
|
||||
*
|
||||
* `{ ok, window, horizon, entries }` — entries ascending by instant, runs and
|
||||
* projections interleaved. `horizon` is the instant past which nothing is
|
||||
* materialised yet, so the UI can draw the line rather than infer it.
|
||||
*
|
||||
* **The instants are UTC and the placement is the client's.** A month grid has
|
||||
* one date axis and the viewer's own zone is what "this month" means to the
|
||||
* person reading it; each entry carries its own `timezone` so the time beside it
|
||||
* reads `20:00 Europe/Berlin` and nobody misreads a shard's local schedule as
|
||||
* their own. That is the split §E's "the timezone belongs to the event" implies:
|
||||
* the event owns the time, the reader owns the calendar.
|
||||
*/
|
||||
async function calendar({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
horizonDays = 14,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const start = from instanceof Date ? from : new Date(from)
|
||||
const end = to instanceof Date ? to : new Date(to)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['from and to must be dates'] }
|
||||
}
|
||||
if (end <= start) {
|
||||
return { ok: false, status: 400, errors: ['to must be after from'] }
|
||||
}
|
||||
if (end - start > MAX_WINDOW_DAYS * recurrence.DAY_MS) {
|
||||
return { ok: false, status: 400, errors: [`the window may span at most ${MAX_WINDOW_DAYS} days`] }
|
||||
}
|
||||
|
||||
const runs = await runsDb.listInWindow({ from: start, to: end, status, scope, seriesId })
|
||||
const entries = runs.map(runEntry)
|
||||
|
||||
// Every instant a run already occupies, keyed by definition. Projections are
|
||||
// per definition at the empty scope, so the definition and the instant are the
|
||||
// whole key -- the same triple the unique index uses, with the scope fixed.
|
||||
const taken = new Set(
|
||||
runs
|
||||
.filter((r) => !r.scope)
|
||||
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
|
||||
)
|
||||
|
||||
// A status filter is a filter on RUNS. A projection has no status, so asking
|
||||
// for "everything that failed" must not answer with a forecast — it would be a
|
||||
// forecast that failed, which is not a thing.
|
||||
// The scope filter behaves the same way, and for the same reason: automatic
|
||||
// expansion is at the empty scope (org lead, 2026-09-02), so a request narrowed
|
||||
// to a named scope has no forecast to give.
|
||||
if (!status && !scope) {
|
||||
const definitions = await definitionsDb.findSchedulable()
|
||||
for (const definition of definitions) {
|
||||
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
|
||||
const schedule = definition.version_spec?.schedule
|
||||
if (!schedule || schedule.kind === 'manual') continue
|
||||
let occurrences = []
|
||||
try {
|
||||
occurrences = recurrence.occurrencesBetween(
|
||||
schedule,
|
||||
definition.timezone || 'UTC',
|
||||
start,
|
||||
end,
|
||||
)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
|
||||
entries.push(projectedEntry(definition, occurrence))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
window: { from: start, to: end },
|
||||
horizon: new Date(now.getTime() + horizonDays * recurrence.DAY_MS),
|
||||
entries: entries.slice(0, MAX_ENTRIES),
|
||||
truncated: entries.length > MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { calendar, MAX_WINDOW_DAYS, MAX_ENTRIES }
|
||||
213
server/src/model/events/eventDefinitions.db.js
Normal file
213
server/src/model/events/eventDefinitions.db.js
Normal file
@@ -0,0 +1,213 @@
|
||||
// ── event_definitions — SQL only ───────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D. The `.db.js` half of the pair: parameterised SQL and hydration,
|
||||
// no policy. Everything that decides whether a write is allowed lives in
|
||||
// `eventDefinitions.model.js`.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
spec: parseJson(row.spec, null),
|
||||
// TINYINT(1) arrives as 0/1. Every reader of this column asks a yes/no
|
||||
// question, and the public model's filters compare against a boolean.
|
||||
listed: Boolean(row.listed),
|
||||
}
|
||||
|
||||
// `current_version` is joined rather than stored: the list screen shows "v3" and
|
||||
// the column that would hold it is a denormalisation of a row this query already
|
||||
// has to reach for the publish date anyway.
|
||||
//
|
||||
// `current_version_verified_at` rides along for the same reason and answers the
|
||||
// same kind of question (Phase 6). A `ready` definition whose version has never
|
||||
// been dry-run will not start on its schedule (§K), and the one place that fact
|
||||
// is worth saying is the screen its author is already looking at -- the
|
||||
// alternative is finding out on the Friday it did not run.
|
||||
const SELECT_LIST = `
|
||||
SELECT d.*, s.name AS series_name, s.slug AS series_slug,
|
||||
v.version AS current_version,
|
||||
v.verified_at AS current_version_verified_at
|
||||
FROM event_definitions d
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
LEFT JOIN event_versions v ON v.id = d.current_version_id
|
||||
`
|
||||
|
||||
const list = async ({ state = null } = {}) => {
|
||||
const rows = state
|
||||
? await query(`${SELECT_LIST} WHERE d.state = ? ORDER BY d.updated_at DESC, d.id DESC`, [state])
|
||||
: await query(`${SELECT_LIST} ORDER BY d.updated_at DESC, d.id DESC`)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE d.id = ?`, [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
const getBySlug = async (slug) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE d.slug = ?`, [slug])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Does any OTHER definition hold this slug? The uniqueness pre-check. */
|
||||
const slugTaken = async (slug, exceptId = null) => {
|
||||
const rows = exceptId
|
||||
? await query('SELECT id FROM event_definitions WHERE slug = ? AND id <> ?', [slug, exceptId])
|
||||
: await query('SELECT id FROM event_definitions WHERE slug = ?', [slug])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const insert = async (d) => {
|
||||
const result = await query(
|
||||
`INSERT INTO event_definitions
|
||||
(title, slug, summary, body, image_url, owner_module, series_id, series_order,
|
||||
concurrency_key, grace_seconds, timezone, listed, spec, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
d.title,
|
||||
d.slug,
|
||||
d.summary,
|
||||
d.body,
|
||||
d.image_url,
|
||||
d.owner_module,
|
||||
d.series_id,
|
||||
d.series_order,
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.created_by,
|
||||
d.created_by,
|
||||
],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
const update = (id, d) =>
|
||||
query(
|
||||
`UPDATE event_definitions
|
||||
SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
|
||||
series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
|
||||
listed = ?, spec = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
d.title,
|
||||
d.slug,
|
||||
d.summary,
|
||||
d.body,
|
||||
d.image_url,
|
||||
d.series_id,
|
||||
d.series_order,
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.updated_by,
|
||||
id,
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* Point a definition at the version it just published, and mark it `ready`.
|
||||
*
|
||||
* One statement, because the two halves are the same fact: `ready` means "a
|
||||
* version has been published and the schedule is live" (§E), so a state without
|
||||
* a `current_version_id` is a lie the scheduler would act on.
|
||||
*/
|
||||
const markReady = (id, versionId, userId) =>
|
||||
query(
|
||||
`UPDATE event_definitions
|
||||
SET state = 'ready', current_version_id = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[versionId, userId, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Every definition the runner should expand a recurrence for (Phase 4).
|
||||
*
|
||||
* `ready` is the whole gate, and it is deliberately the only one: EVENTS.md §E
|
||||
* defines `ready` as "a version has been published and the schedule is live", so
|
||||
* publishing IS the switch and archiving is how an operator turns a recurrence
|
||||
* off. A separate schedule-enabled flag would be a second answer to a question
|
||||
* `state` already answers, and the two would eventually disagree.
|
||||
*
|
||||
* The VERSION's spec is joined rather than the definition's working copy: the
|
||||
* draft is what an author is midway through editing, and a half-typed `weekly`
|
||||
* must never materialise anything. The pinned spec comes back with it, so the
|
||||
* whole expansion is one round trip.
|
||||
*
|
||||
* The series columns are here for the CALENDAR rather than the runner, which
|
||||
* ignores them: a projected occurrence has to be filterable and labellable by
|
||||
* its arc exactly as a materialised run is, and a second query to learn the name
|
||||
* of a row this one already reached would be two round trips for a join.
|
||||
*/
|
||||
const findSchedulable = async ({ listedOnly = false } = {}) => {
|
||||
const rows = await query(
|
||||
`SELECT d.id, d.title, d.slug, d.summary, d.image_url, d.timezone, d.grace_seconds,
|
||||
d.concurrency_key, d.current_version_id, d.series_id, v.spec AS version_spec,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_definitions d
|
||||
JOIN event_versions v ON v.id = d.current_version_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE d.state = 'ready'${listedOnly ? ' AND d.listed = 1' : ''}
|
||||
ORDER BY d.id`,
|
||||
)
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition by slug, for the PUBLIC event page (Phase 14a).
|
||||
*
|
||||
* `ready` and `listed` are both in the WHERE rather than checked by the caller,
|
||||
* so an unlisted event answers exactly as a nonexistent one does — a 404 that
|
||||
* cannot be told from "no such slug". A caller that filtered afterwards would
|
||||
* be one forgotten early-return away from publishing a draft.
|
||||
*
|
||||
* An ARCHIVED definition is deliberately absent too. Archiving is what delete
|
||||
* means on the admin screen, and a page that kept answering afterwards would
|
||||
* make the only delete this feature has do nothing an operator could see.
|
||||
*/
|
||||
const getPublicBySlug = async (slug) => {
|
||||
const [row] = await query(
|
||||
`${SELECT_LIST} WHERE d.slug = ? AND d.state = 'ready' AND d.listed = 1`,
|
||||
[slug],
|
||||
)
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Every listed, ready definition in one series, in the arc's own order. */
|
||||
const listPublicBySeries = async (seriesId) => {
|
||||
const rows = await query(
|
||||
`${SELECT_LIST}
|
||||
WHERE d.series_id = ? AND d.state = 'ready' AND d.listed = 1
|
||||
ORDER BY d.series_order, d.id`,
|
||||
[seriesId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
|
||||
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
|
||||
* Archiving is what "delete" means on this screen, and the row keeps its history.
|
||||
*/
|
||||
const archive = (id, userId) =>
|
||||
query("UPDATE event_definitions SET state = 'archived', updated_by = ? WHERE id = ?", [userId, id])
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
getBySlug,
|
||||
getPublicBySlug,
|
||||
listPublicBySeries,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
insert,
|
||||
update,
|
||||
markReady,
|
||||
archive,
|
||||
}
|
||||
413
server/src/model/events/eventDefinitions.model.js
Normal file
413
server/src/model/events/eventDefinitions.model.js
Normal file
@@ -0,0 +1,413 @@
|
||||
// ── Event definitions — the save path ──────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and "Versioning, and editing a live event". A definition is
|
||||
// operator-authored data, and this file is the boundary that decides whether a
|
||||
// version of it may exist. The authoring UI (Phase 3, then Phase 13) will
|
||||
// re-check some of this for the sake of a good inline error; that second copy is
|
||||
// expected to drift, so this one is the one that decides, and a definition
|
||||
// arriving by any other route gets the same answer.
|
||||
//
|
||||
// **The three rules with teeth, and each is a rule about time rather than about
|
||||
// shape:**
|
||||
//
|
||||
// 1. Publishing SNAPSHOTS. It copies the working spec into an immutable
|
||||
// `event_versions` row and points `current_version_id` at it. Editing
|
||||
// afterwards is free and does not touch the row a live run pinned.
|
||||
// 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module
|
||||
// must not make an author's work uneditable, and it must not let a version be
|
||||
// published that names a verb nobody can perform.
|
||||
// 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so
|
||||
// a hard delete of a definition that has ever run is refused by the database
|
||||
// anyway — and the row's history is the thing an audit reads.
|
||||
|
||||
const db = require('./eventDefinitions.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const spec = require('../../events/spec')
|
||||
const authorize = require('../../events/authorize')
|
||||
const verifier = require('../../events/verify')
|
||||
const registries = require('../../modules/registries')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
|
||||
const MAX_TITLE = 200
|
||||
const MAX_SUMMARY = 500
|
||||
const MAX_URL = 500
|
||||
const MAX_CONCURRENCY_KEY = 190
|
||||
|
||||
// A day either side of §D's default. Below a minute the grace window cannot
|
||||
// survive a single slow boot; above a day a "missed" occurrence would start
|
||||
// silently the following afternoon, which is the exact behaviour §E forbids.
|
||||
const MIN_GRACE_SECONDS = 60
|
||||
const MAX_GRACE_SECONDS = 86_400
|
||||
|
||||
/**
|
||||
* IANA zone names, checked against the platform's own database rather than a
|
||||
* list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node
|
||||
* ships the full tzdata — so this is the same check Phase 4's occurrence
|
||||
* arithmetic will make, asked one screen earlier where an operator can fix it.
|
||||
*/
|
||||
function isTimezone(tz) {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
const trimOrNull = (v, max) => {
|
||||
if (v === undefined || v === null) return null
|
||||
const s = String(v).trim()
|
||||
return s === '' ? null : s.slice(0, max)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an incoming definition against the registries and the schema.
|
||||
*
|
||||
* `{ ok: true, definition }` with a normalised row ready for insert/update, or
|
||||
* `{ ok: false, errors }` listing every problem rather than the first.
|
||||
*
|
||||
* `existing` is the row being edited, or null on a create. It is what lets a
|
||||
* dormant step survive: the ids already in the saved spec widen what the spec
|
||||
* validator will accept, so an uninstall is never destructive after the fact.
|
||||
*/
|
||||
async function validate(input, { existing = null } = {}) {
|
||||
const errors = []
|
||||
const body = isPlainObject(input) ? input : {}
|
||||
|
||||
const title = trimOrNull(body.title, MAX_TITLE)
|
||||
if (!title) errors.push('title is required')
|
||||
|
||||
const summary = trimOrNull(body.summary, MAX_SUMMARY)
|
||||
const imageUrl = trimOrNull(body.imageUrl, MAX_URL)
|
||||
const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY)
|
||||
|
||||
// The storyline. Sanitized on write, exactly as a wiki page and a forum post
|
||||
// are: it is author-supplied HTML that ends up on a public page.
|
||||
const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body))
|
||||
|
||||
const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC'
|
||||
if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`)
|
||||
|
||||
const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds
|
||||
const graceSeconds = Number(graceRaw)
|
||||
if (
|
||||
!Number.isInteger(graceSeconds) ||
|
||||
graceSeconds < MIN_GRACE_SECONDS ||
|
||||
graceSeconds > MAX_GRACE_SECONDS
|
||||
) {
|
||||
errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`)
|
||||
}
|
||||
|
||||
let seriesId = null
|
||||
if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') {
|
||||
seriesId = Number(body.seriesId)
|
||||
if (!Number.isInteger(seriesId) || seriesId < 1) {
|
||||
errors.push('seriesId must be an integer')
|
||||
seriesId = null
|
||||
} else if (!(await seriesDb.exists(seriesId))) {
|
||||
// Checked here as well as by the foreign key, because a 1452 reaching a
|
||||
// controller is a 500 and this is a 400 an author can act on.
|
||||
errors.push(`seriesId ${seriesId} does not exist`)
|
||||
seriesId = null
|
||||
}
|
||||
} else if (existing) {
|
||||
seriesId = existing.series_id
|
||||
}
|
||||
|
||||
const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder
|
||||
const seriesOrder = Number(seriesOrderRaw)
|
||||
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
|
||||
|
||||
// Whether this event is announced on the public calendar (Phase 14a). It is
|
||||
// NOT whether it may run: `state` answers that, and the two are separate
|
||||
// precisely because publishing is what makes a definition runnable — an
|
||||
// unlisted event still schedules, still runs and is still on the admin
|
||||
// calendar. A missing key means "leave it as it was", and a NEW definition
|
||||
// defaults to listed, which is the column's own default and the ordinary
|
||||
// case; unlisting is the deliberate act.
|
||||
const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
|
||||
|
||||
// ── the spec ──
|
||||
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
|
||||
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
|
||||
const checked = spec.validate(rawSpec, { knownActionIds: known })
|
||||
if (!checked.ok) errors.push(...checked.errors)
|
||||
|
||||
// ── the slug ──
|
||||
//
|
||||
// Derived from the title on create and FROZEN afterwards, like a Team's: the
|
||||
// public event page lives at it, and a retitle must not break a link somebody
|
||||
// posted in Discord. An author who genuinely needs a different address makes a
|
||||
// new definition.
|
||||
let slug = existing?.slug || null
|
||||
if (!slug) {
|
||||
const stem = slugify(title || '') || 'event'
|
||||
const taken = (await db.list()).map((d) => d.slug)
|
||||
slug = uniqueSlug(stem, taken, { fallback: 'event' })
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
definition: {
|
||||
title,
|
||||
slug,
|
||||
summary,
|
||||
body: storyline,
|
||||
image_url: imageUrl,
|
||||
owner_module: existing?.owner_module ?? null,
|
||||
series_id: seriesId,
|
||||
series_order: seriesOrder,
|
||||
concurrency_key: concurrencyKey,
|
||||
grace_seconds: graceSeconds,
|
||||
timezone,
|
||||
listed,
|
||||
spec: checked.spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a draft. */
|
||||
async function create(input, userId, { role = null } = {}) {
|
||||
const result = await validate(input)
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a definition.
|
||||
*
|
||||
* An `archived` definition is not editable. That is the one state check here,
|
||||
* and it is a real one rather than a formality: archiving is how a definition is
|
||||
* retired, and a retired definition that can still be edited is a definition
|
||||
* somebody will edit and then wonder why it never runs.
|
||||
*/
|
||||
async function save(id, input, userId, { role = null } = {}) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] }
|
||||
}
|
||||
const result = await validate(input, { existing })
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
await db.update(id, { ...result.definition, updated_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish: snapshot the working spec into an immutable version and go `ready`.
|
||||
*
|
||||
* The spec is re-validated here against the registries as they stand RIGHT NOW,
|
||||
* not trusted from the save that wrote it. A module uninstalled between the two
|
||||
* is the whole reason: the save was legitimate, and publishing a version whose
|
||||
* steps name a verb nobody can perform would be a run that fails at dispatch
|
||||
* with the world half-changed.
|
||||
*/
|
||||
async function publish(id, userId) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be published'] }
|
||||
}
|
||||
|
||||
const checked = spec.validate(existing.spec, {
|
||||
knownActionIds: spec.actionIdsIn(existing.spec),
|
||||
})
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
const publishable = spec.publishable(checked.spec)
|
||||
if (!publishable.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
errors: [
|
||||
`cannot publish: no module registers ${publishable.dormant.join(', ')}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
if (!checked.spec.phases.some((p) => p.steps.length)) {
|
||||
// An empty event publishes cleanly and then does nothing, which looks like a
|
||||
// broken run rather than an empty one. Refusing costs an author one click and
|
||||
// saves an operator a diagnosis.
|
||||
return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] }
|
||||
}
|
||||
|
||||
const version = await versionsDb.nextVersion(id)
|
||||
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
|
||||
await db.markReady(id, versionId, userId)
|
||||
|
||||
// Occurrences already materialised ahead of their instant move to the new
|
||||
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
|
||||
// rather than only counted, because "which version did this run actually use"
|
||||
// is the first question an audit asks and the pin is no longer immutable while
|
||||
// a run is still `scheduled`.
|
||||
const pending = await runsDb.listScheduledFor(id)
|
||||
const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
|
||||
const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
|
||||
for (const run of stale) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'run.status',
|
||||
detail: {
|
||||
to: 'scheduled',
|
||||
repinned: true,
|
||||
fromVersionId: run.version_id,
|
||||
toVersionId: versionId,
|
||||
toVersion: version,
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive.
|
||||
*
|
||||
* Refused while a run of this definition is still in flight — not because the
|
||||
* database would object (it would not; archiving is an UPDATE), but because the
|
||||
* screen the run is on reads its title and state from here, and retiring a
|
||||
* definition mid-run makes the console describe something that is no longer
|
||||
* supposed to exist. Cancel the run, then archive.
|
||||
*/
|
||||
async function archive(id, userId) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') return { ok: true, definition: existing }
|
||||
|
||||
const active = await runsDb.countActiveForDefinition(id)
|
||||
if (active > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
errors: [`cannot archive: ${active} run(s) of this definition are still in flight`],
|
||||
}
|
||||
}
|
||||
await db.archive(id, userId)
|
||||
return { ok: true, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The role floor on a spec's steps (§K, Phase 6).
|
||||
*
|
||||
* §K's table reads "any step whose action is above `notify` — `admin` only", and
|
||||
* the line falls between `inspect` and `change` for the reason the default-off
|
||||
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
* nothing, and an editor who cannot author a step that WAITS has an authoring
|
||||
* role that cannot author.
|
||||
*
|
||||
* Checked at SAVE rather than only at publish, which is the difference between
|
||||
* telling an editor now and telling them after they have written twelve steps.
|
||||
* Publish re-checks anyway — it re-checks everything, against the registries as
|
||||
* they stand at that moment — because an action's risk class is a module's
|
||||
* declaration and a module can be upgraded between the two.
|
||||
*/
|
||||
function worldChangingSteps(specValue) {
|
||||
const out = []
|
||||
for (const phase of specValue?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (action && authorize.changesWorld(action)) out.push(action)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function checkRoleFloor(specValue, role) {
|
||||
if (!role || role === 'admin') return null
|
||||
const blocked = worldChangingSteps(specValue)
|
||||
if (!blocked.length) return null
|
||||
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
|
||||
// Agreement, because this sentence is read by the person it refuses. The list
|
||||
// is almost always one long -- an editor adds one world-changing step and is
|
||||
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
|
||||
// and it reads as a bug in the sentence rather than a rule about the step.
|
||||
const one = names.length === 1
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
errors: [
|
||||
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a definition, and record the pass when there is a version to record it
|
||||
* on (Phase 6).
|
||||
*
|
||||
* The target follows the definition's state: a `ready` definition is verified
|
||||
* against the version that would actually run, a draft against the working spec
|
||||
* the author is still holding. See `events/verify.js` for why that is one act at
|
||||
* two moments rather than two rules.
|
||||
*/
|
||||
async function verify(id, user) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
|
||||
}
|
||||
|
||||
const version =
|
||||
existing.state === 'ready' && existing.current_version_id
|
||||
? await versionsDb.getById(existing.current_version_id)
|
||||
: null
|
||||
const target = version?.spec || existing.spec
|
||||
if (!target?.phases?.length) {
|
||||
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
|
||||
}
|
||||
|
||||
const report = await verifier.verifySpec(target, { user })
|
||||
|
||||
if (version && report.ok) {
|
||||
await versionsDb.markVerified(version.id, user?.id || null)
|
||||
// Written to every run already pinned to this version, because that is where
|
||||
// an operator asks the question: a scheduled occurrence that was being held
|
||||
// is now going to start, and the line saying why belongs on it.
|
||||
for (const run of await runsDb.listScheduledFor(id)) {
|
||||
if (Number(run.version_id) !== Number(version.id)) continue
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'version.verified',
|
||||
detail: { versionId: version.id, version: version.version, by: user?.id || null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
// Which spec was verified, said plainly, because the two answer different
|
||||
// questions and a report that did not say would be read as the other one.
|
||||
target: version ? 'version' : 'draft',
|
||||
versionId: version?.id || null,
|
||||
version: version?.version || null,
|
||||
recorded: Boolean(version && report.ok),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
save,
|
||||
publish,
|
||||
archive,
|
||||
verify,
|
||||
checkRoleFloor,
|
||||
isTimezone,
|
||||
MIN_GRACE_SECONDS,
|
||||
MAX_GRACE_SECONDS,
|
||||
}
|
||||
23
server/src/model/events/eventJson.js
Normal file
23
server/src/model/events/eventJson.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// ── One JSON reader for the whole events model ─────────────────────────────
|
||||
//
|
||||
// JSON columns come back from the driver already parsed on some MariaDB/driver
|
||||
// combinations and as a string on others — it depends on whether the column is a
|
||||
// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as. Every
|
||||
// read in this directory goes through this, so no caller has to know which it
|
||||
// got.
|
||||
//
|
||||
// Lifted from `engagementRules.db.js`, which learned it first, and hoisted into
|
||||
// its own file here rather than copied into six: six copies of a fallback is six
|
||||
// chances for one of them to fall back to `{}` where the reader expects `[]`.
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
if (value === null || value === undefined) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseJson }
|
||||
187
server/src/model/events/eventPhaseGates.db.js
Normal file
187
server/src/model/events/eventPhaseGates.db.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// ── event_run_phase_gates — SQL only ───────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §E, and Phase 5 of EVENTS_PLAN.md. A phase used to advance on one
|
||||
// fact — every step terminal — and that fact lives in `event_run_steps`. An
|
||||
// advance CONDITION is a second fact, and it is the only one in this feature
|
||||
// that is not derivable from a row somebody already wrote: `{ on:
|
||||
// 'uo.champ.boss_up', count: 3 }` counts things that happen between one tick and
|
||||
// the next, and the runner is not running when they happen. A gate row is where
|
||||
// a firing is counted at the moment it fires.
|
||||
//
|
||||
// **Two writers, and they are not the same process leg.** The RUNNER opens a
|
||||
// gate (at phase entry) and closes an `after` one (when its deadline passes);
|
||||
// the EMIT PATH increments and closes an `on` one. Everything here is therefore
|
||||
// written as a single guarded statement rather than a read-then-write, which is
|
||||
// the same argument `event_run_budget`'s conditional increment makes one phase
|
||||
// early and the same one `runsDb.transition` makes for a status.
|
||||
//
|
||||
// **Nothing here throws at the emit path.** `observe` is called from inside a
|
||||
// game-event handler by way of `ctx.events.emit`, exactly as `engine.dispatch`
|
||||
// is, and a database problem of core's must not become a module's control flow.
|
||||
// The catch lives in `events/gates.js`; this file is the statements.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
conditions: parseJson(row.conditions, null),
|
||||
last_event: parseJson(row.last_event, null),
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a phase's gate. **INSERT IGNORE against `uq_evgate_phase`**, so a process
|
||||
* that died between entering a phase and getting here opens no second gate on
|
||||
* the next tick — the idempotence `materialisePhase` has, for the same reason.
|
||||
*
|
||||
* Answers whether a row was created, which is what lets the caller log
|
||||
* `phase.entered`'s gate detail exactly once.
|
||||
*/
|
||||
async function open({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = new Date() }) {
|
||||
// `due_at` is computed here, once, from the moment the phase was entered —
|
||||
// never re-derived on a later tick from a `now` that has moved. A deadline
|
||||
// recomputed every fifteen seconds is a deadline that never arrives.
|
||||
const dueAt = kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_phase_gates
|
||||
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
runId,
|
||||
phase,
|
||||
kind,
|
||||
afterSeconds,
|
||||
triggerId,
|
||||
conditions === null ? null : JSON.stringify(conditions),
|
||||
needed,
|
||||
now,
|
||||
dueAt,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/** One run's gate for one phase, or null. */
|
||||
const forPhase = async (runId, phase) =>
|
||||
hydrate(
|
||||
(
|
||||
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? AND phase = ? LIMIT 1', [
|
||||
runId,
|
||||
phase,
|
||||
])
|
||||
)[0] || null,
|
||||
)
|
||||
|
||||
/** Every gate a run has ever opened, oldest first — what the run console reads. */
|
||||
const listForRun = async (runId) =>
|
||||
(
|
||||
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? ORDER BY entered_at, id', [
|
||||
runId,
|
||||
])
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* Every OPEN gate waiting on one trigger, with the run's status and phase.
|
||||
*
|
||||
* This is the emit path's only query and the one index in this feature on a hot
|
||||
* path. The join is what keeps a gate belonging to a cancelled run from counting
|
||||
* for ever: a run that will never advance again must stop tallying, and its
|
||||
* row's `satisfied_at` is not what says so.
|
||||
*
|
||||
* **`paused` counts.** The world does not stop because an operator paused the
|
||||
* console, and discarding firings that arrived during a pause would make pause a
|
||||
* destructive control — the tally an operator came back to would be lower than
|
||||
* the one they left, with nothing recording the difference.
|
||||
*/
|
||||
const openForTrigger = async (triggerId) =>
|
||||
(
|
||||
await query(
|
||||
`SELECT g.* FROM event_run_phase_gates g
|
||||
JOIN event_runs r ON r.id = g.run_id
|
||||
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
|
||||
AND r.status IN ('running','paused')
|
||||
AND r.current_phase = g.phase
|
||||
LIMIT 200`,
|
||||
[triggerId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* Count one matching firing, and close the gate if that was the last one needed.
|
||||
*
|
||||
* **One statement, with the threshold inside it.** Two emits arriving together
|
||||
* each add one and exactly one of them crosses `needed`; a read-then-write would
|
||||
* let both see 2 of 3 and neither satisfy, or both satisfy and advance a phase
|
||||
* twice. `WHERE satisfied_at IS NULL` is what makes a late arrival a no-op
|
||||
* rather than a tally that keeps climbing after the phase moved on.
|
||||
*
|
||||
* Answers `{ counted, satisfied }` read back from the row, so the caller logs
|
||||
* the tally the database actually holds rather than the one it predicted.
|
||||
*/
|
||||
async function count(gateId, { lastEvent = null, now = new Date() } = {}) {
|
||||
// **THE INCREMENT MUST BE LAST, and this is not style.** MariaDB evaluates an
|
||||
// UPDATE's SET assignments LEFT TO RIGHT, each one seeing the values already
|
||||
// assigned by the ones before it — which is a documented departure from
|
||||
// standard SQL, and it is invisible in a stub. With `tally = tally + 1` first,
|
||||
// the CASE that follows reads the ALREADY-INCREMENTED tally, so `tally + 1 >=
|
||||
// needed` is really `new + 1 >= needed` and a gate needing two firings closes
|
||||
// on the first. Written this way, both CASEs see the old tally and say exactly
|
||||
// what they read as. `eventRunnerSql.test.js` is what catches a reorder, and
|
||||
// it is what caught this one.
|
||||
const result = await query(
|
||||
`UPDATE event_run_phase_gates
|
||||
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
|
||||
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
|
||||
last_event = ?,
|
||||
last_event_at = ?,
|
||||
tally = tally + 1
|
||||
WHERE id = ? AND satisfied_at IS NULL`,
|
||||
[now, lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
|
||||
)
|
||||
if (Number(result?.affectedRows || 0) !== 1) return { counted: false, satisfied: false }
|
||||
const row = await byId(gateId)
|
||||
return { counted: true, satisfied: Boolean(row?.satisfied_at), tally: row?.tally ?? null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a firing was seen and did NOT match.
|
||||
*
|
||||
* Only `last_event_at` and `last_event` move: the tally is what the phase is
|
||||
* waiting on, and a near miss is not progress. It is recorded at all because
|
||||
* "the boss did spawn, in the wrong region" and "no boss has spawned" are
|
||||
* different answers to the operator's question, and only this column can tell
|
||||
* them apart on a screen.
|
||||
*/
|
||||
async function noteNearMiss(gateId, { lastEvent = null, now = new Date() } = {}) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_phase_gates
|
||||
SET last_event = ?, last_event_at = ?
|
||||
WHERE id = ? AND satisfied_at IS NULL`,
|
||||
[lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a gate for a reason that is not a matching firing: `'elapsed'` when an
|
||||
* `after` deadline passed, `'forced'` when a human pressed advance.
|
||||
*
|
||||
* Guarded on `satisfied_at IS NULL` like everything else here, so a force that
|
||||
* races the tick that would have opened the gate anyway loses harmlessly and the
|
||||
* log records whichever actually happened rather than both.
|
||||
*/
|
||||
async function satisfy(gateId, by, { userId = null, now = new Date() } = {}) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_phase_gates
|
||||
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
|
||||
WHERE id = ? AND satisfied_at IS NULL`,
|
||||
[now, by, userId, gateId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
const byId = async (id) =>
|
||||
hydrate((await query('SELECT * FROM event_run_phase_gates WHERE id = ? LIMIT 1', [id]))[0] || null)
|
||||
|
||||
module.exports = { open, forPhase, byId, listForRun, openForTrigger, count, noteNearMiss, satisfy }
|
||||
449
server/src/model/events/eventPublic.model.js
Normal file
449
server/src/model/events/eventPublic.model.js
Normal file
@@ -0,0 +1,449 @@
|
||||
// ── The public event surface ───────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md § API surface, and Phase 14a of EVENTS_PLAN.md: the calendar an
|
||||
// anonymous visitor reads, one event's page, and an arc.
|
||||
//
|
||||
// **This file is a projection, and the projection is the security boundary.**
|
||||
// Every other reader of these tables is staff, and every field they are shown is
|
||||
// one somebody with a role was allowed to see. What comes out of here is read by
|
||||
// nobody at all, so the rule is the opposite of the admin shapes': nothing is
|
||||
// spread, and a field reaches a public entry because a line below put it there.
|
||||
// The day somebody adds a column to `event_runs` — a claim token, an operator's
|
||||
// note, a last error — a `{ ...run }` anywhere here would publish it, silently,
|
||||
// in the release after the one anybody reviewed.
|
||||
//
|
||||
// Three things are absent from every shape below, and each is a decision:
|
||||
//
|
||||
// • **The spec.** Phases, steps, actions and their params are the plan for
|
||||
// changing a live world. A visitor is told what is happening and when, and
|
||||
// the LABEL of the phase while it is happening; the steps are the operator's.
|
||||
// • **Health, cleanup, claims and errors.** A degraded run is a fact about the
|
||||
// deployment's plumbing. "The event is running" is the fact about the event.
|
||||
// • **`member_key`.** It is the game's own identifier for a character, it is
|
||||
// module-opaque, and core cannot say what it discloses — so it stays unsent
|
||||
// even on a results table where every other column is published.
|
||||
//
|
||||
// **What makes something public is `listed` AND `ready` AND not a rehearsal**,
|
||||
// and all three live in SQL (`eventDefinitions.db.getPublicBySlug`, and
|
||||
// `publicOnly` on `eventRuns.db.listInWindow`). Filtering in JavaScript after
|
||||
// the read would work exactly as well, right up until the first caller that
|
||||
// forgot to.
|
||||
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const participantsDb = require('./eventRunParticipants.db')
|
||||
const calendarModel = require('./eventCalendar.model')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
|
||||
// The public calendar's window when a caller names neither end: a few days BACK
|
||||
// through a month out. A visitor arriving at /site/events wants "what is on", and
|
||||
// a client that had to compute a window before it could ask anything would make
|
||||
// every deep link carry two ISO instants.
|
||||
//
|
||||
// **The backward tail is not padding — it is the "recent" in §I's "upcoming, live
|
||||
// and recent".** The default used to start at `now`, so an event that finished an
|
||||
// hour ago was already gone and a visitor had nowhere to find the results of the
|
||||
// thing they had just attended. The LIVE half is answered by `listInWindow`'s
|
||||
// overlap test rather than by this number, so the tail only has to be long enough
|
||||
// to be a "recently" a reader would recognise.
|
||||
const DEFAULT_WINDOW_DAYS = 31
|
||||
const DEFAULT_RECENT_DAYS = 7
|
||||
|
||||
// How many past occurrences an event page carries. It shows what is next and
|
||||
// what happened recently; the whole history of a three-year-old weekly event is
|
||||
// a different screen and nobody has asked for one.
|
||||
const PAST_RUNS = 10
|
||||
const RESULTS_LIMIT = 100
|
||||
|
||||
// The status words a visitor is told. `paused` maps to `live` deliberately: an
|
||||
// operator holding a run for two minutes while they deal with something is not a
|
||||
// state a public page should render, and a page that said "paused" would invite
|
||||
// a question whose answer is internal.
|
||||
const PUBLIC_STATUS = {
|
||||
scheduled: 'scheduled',
|
||||
starting: 'live',
|
||||
running: 'live',
|
||||
paused: 'live',
|
||||
ending: 'live',
|
||||
completed: 'completed',
|
||||
cancelled: 'cancelled',
|
||||
failed: 'cancelled',
|
||||
missed: 'cancelled',
|
||||
}
|
||||
|
||||
/**
|
||||
* The public status word for a run.
|
||||
*
|
||||
* **`failed` and `missed` are published as `cancelled`**, which is the mapping
|
||||
* worth defending. To a visitor the three are one event: it was on the calendar
|
||||
* and it did not happen. The difference between them is entirely about the
|
||||
* deployment — `failed` names broken machinery, `missed` names a process that
|
||||
* was down when the schedule came round — so publishing either word would tell a
|
||||
* stranger something true about the server and nothing about the event.
|
||||
*/
|
||||
const publicStatus = (status) => PUBLIC_STATUS[status] || 'scheduled'
|
||||
|
||||
/** Is this a run a visitor should be shown as happening now? */
|
||||
const isLive = (status) => publicStatus(status) === 'live'
|
||||
|
||||
/**
|
||||
* The label of the phase a run is in, resolved from the PINNED version's spec.
|
||||
*
|
||||
* A phase id is a slug an author typed and the label is what they meant it to
|
||||
* read as, so a page rendering the id would show `phase-2` to the public. A
|
||||
* phase the spec does not name answers null and the page shows nothing, which is
|
||||
* the right answer for a version edited since: the run pinned the old spec and
|
||||
* the old spec is what it is executing.
|
||||
*/
|
||||
function phaseLabel(spec, phaseId) {
|
||||
if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
|
||||
const phase = spec.phases.find((p) => p && p.id === phaseId)
|
||||
return (phase && (phase.label || phase.id)) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* One calendar entry, from a materialised run.
|
||||
*
|
||||
* **`runId` is published because the event page already publishes it** on every
|
||||
* occurrence, and `?run=` takes it. The calendar was the one public shape that
|
||||
* named a run without saying which, so a client holding a run id from elsewhere
|
||||
* (a module's map marker) had no way to find its event but to fetch every event
|
||||
* page. A projection has none: nothing is committed to it.
|
||||
*/
|
||||
const publicRunEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesName: run.series_name || null,
|
||||
seriesSlug: run.series_slug || null,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
status: publicStatus(run.status),
|
||||
live: isLive(run.status),
|
||||
})
|
||||
|
||||
/**
|
||||
* One calendar entry, from a projection.
|
||||
*
|
||||
* A projection is arithmetic past the materialisation horizon (§I), and the
|
||||
* public entry keeps the distinction for the visitor's version of the operator's
|
||||
* reason: a forecast three weeks out is a plan rather than a booking, and a page
|
||||
* drawing the two identically would promise something nothing has committed to.
|
||||
* `adjusted` rides along because a DST-shifted occurrence is worth explaining
|
||||
* before it happens rather than after.
|
||||
*/
|
||||
const publicProjectedEntry = (definition, occurrence) => ({
|
||||
kind: 'projected',
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
seriesName: definition.series_name || null,
|
||||
seriesSlug: definition.series_slug || null,
|
||||
scheduledFor: occurrence.at,
|
||||
timezone: definition.timezone,
|
||||
status: 'scheduled',
|
||||
live: false,
|
||||
adjusted: occurrence.adjusted,
|
||||
shiftMinutes: occurrence.shiftMinutes,
|
||||
})
|
||||
|
||||
/**
|
||||
* The public calendar for a window.
|
||||
*
|
||||
* **The run half is read here rather than borrowed from `eventCalendar.model`**,
|
||||
* and the reason is the file header's: that model answers with `status`,
|
||||
* `health`, `version` and `waitingSteps` on every entry, so reusing it would
|
||||
* mean building the public answer by DELETING fields from an operator's, which
|
||||
* is the direction that fails silently. The arithmetic IS shared —
|
||||
* `occurrencesBetween` is the same function the runner calls, so a forecast
|
||||
* still cannot disagree with what later appears — and so are the window bound
|
||||
* and the entry cap, which are a defence against an expensive query on the one
|
||||
* surface that has no login in front of it.
|
||||
*/
|
||||
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
|
||||
// The default `to` is measured from NOW, not from `start` — otherwise the
|
||||
// backward tail would silently push the horizon a week further out and a caller
|
||||
// naming only `from` would get a different span than one naming neither.
|
||||
const start = from
|
||||
? new Date(from)
|
||||
: new Date(new Date(now).getTime() - DEFAULT_RECENT_DAYS * recurrence.DAY_MS)
|
||||
const end = to
|
||||
? new Date(to)
|
||||
: new Date(new Date(now).getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['from and to must be dates'] }
|
||||
}
|
||||
if (end <= start) {
|
||||
return { ok: false, status: 400, errors: ['to must be after from'] }
|
||||
}
|
||||
if (end - start > calendarModel.MAX_WINDOW_DAYS * recurrence.DAY_MS) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
errors: [`the window may span at most ${calendarModel.MAX_WINDOW_DAYS} days`],
|
||||
}
|
||||
}
|
||||
|
||||
const runs = await runsDb.listInWindow({ from: start, to: end, seriesId, publicOnly: true })
|
||||
const entries = runs.map(publicRunEntry)
|
||||
|
||||
// Every instant a run already occupies, so the fortnight inside the horizon is
|
||||
// not drawn twice — and so a CANCELLED occurrence is not re-forecast as though
|
||||
// it were still coming. The same key the admin calendar uses, for the same
|
||||
// reason: projections are per definition at the empty scope.
|
||||
const taken = new Set(
|
||||
runs
|
||||
.filter((r) => !r.scope)
|
||||
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
|
||||
)
|
||||
|
||||
const definitions = await definitionsDb.findSchedulable({ listedOnly: true })
|
||||
for (const definition of definitions) {
|
||||
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
|
||||
const schedule = definition.version_spec?.schedule
|
||||
if (!schedule || schedule.kind === 'manual') continue
|
||||
let occurrences = []
|
||||
try {
|
||||
// **Forecast from `now`, never from `start`.** The default window now
|
||||
// reaches a week backwards so that "recent" has somewhere to live, and a
|
||||
// projection into that tail would advertise an occurrence that did not
|
||||
// happen — a run that WAS created is a real row and arrives above, and one
|
||||
// that was not is a slot the runner has already passed. A forecast is about
|
||||
// the future; the tail is about the past. Only the materialised half fills
|
||||
// it.
|
||||
const forecastFrom = start > now ? start : new Date(now)
|
||||
if (forecastFrom < end) {
|
||||
occurrences = recurrence.occurrencesBetween(
|
||||
schedule,
|
||||
definition.timezone || 'UTC',
|
||||
forecastFrom,
|
||||
end,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// A version whose schedule the recurrence engine will not read is one the
|
||||
// runner will not expand either. The calendar then shows that definition's
|
||||
// materialised runs and no forecast, rather than failing the whole page.
|
||||
continue
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
|
||||
entries.push(publicProjectedEntry(definition, occurrence))
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
window: { from: start, to: end },
|
||||
entries: entries.slice(0, calendarModel.MAX_ENTRIES),
|
||||
truncated: entries.length > calendarModel.MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
/** One participant, as a results table publishes them. */
|
||||
const publicParticipant = (p) => ({
|
||||
// NOT `memberKey` — see the file header. A display name is whatever the module
|
||||
// chose to put in `meta`, because core has no name for a character and must
|
||||
// not invent one from the key.
|
||||
name: (p.meta && (p.meta.name || p.meta.displayName)) || null,
|
||||
score: p.score,
|
||||
rank: p.rank_at,
|
||||
meta: p.meta || null,
|
||||
})
|
||||
|
||||
/** One occurrence, as an event page lists it. */
|
||||
const publicOccurrence = (run, spec) => ({
|
||||
runId: run.id,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
startedAt: run.started_at,
|
||||
endedAt: run.ended_at,
|
||||
status: publicStatus(run.status),
|
||||
live: isLive(run.status),
|
||||
scope: run.scope || null,
|
||||
phase: isLive(run.status) ? phaseLabel(spec, run.current_phase) : null,
|
||||
resultsPublishedAt: run.results_published_at || null,
|
||||
})
|
||||
|
||||
/**
|
||||
* One event's public page: the storyline, its arc, its occurrences, and a
|
||||
* results table when there is one to show.
|
||||
*
|
||||
* **`runId` selects WHICH occurrence the results are about, and it is optional
|
||||
* for a reason that exists only because of the announcements.** The page lives
|
||||
* at the definition's slug, so a weekly event has one address and a visitor
|
||||
* arriving at it should be shown what is next. But an `event.run.completed` mail
|
||||
* is about ONE occurrence, and a link in it that opened next Friday's would
|
||||
* answer a different question from the one the reader clicked. So the trigger's
|
||||
* `eventUrl` carries `?run=`, and this is what resolves it.
|
||||
*
|
||||
* **A `runId` that does not belong to this definition is ignored rather than
|
||||
* refused.** It names some other event's run, or none; the honest answer to
|
||||
* "show me this event" is still this event, and a 404 for the whole page would
|
||||
* turn a stale link in a months-old mail into a dead end rather than a page
|
||||
* about the thing the mail was about.
|
||||
*/
|
||||
async function event(slug, { runId = null } = {}) {
|
||||
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
|
||||
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
const series = definition.series_id ? await seriesDb.getById(definition.series_id) : null
|
||||
const runs = await runsDb.listPublicForDefinition(definition.id, PAST_RUNS + 20)
|
||||
|
||||
const now = Date.now()
|
||||
const live = runs.filter((r) => isLive(r.status))
|
||||
|
||||
// **Split on the instant, not on the status**, and the difference is visible
|
||||
// in both directions. A `missed` run is in the past whatever its status says,
|
||||
// and so is a `scheduled` one whose moment went by while the runner had not
|
||||
// reached it — but a run an operator CANCELLED next Friday is still next
|
||||
// Friday, and filing it under "previously" tells a visitor it already
|
||||
// happened, which is the one thing that is certainly untrue about it. That a
|
||||
// cancelled occurrence still appears under what is coming is the point:
|
||||
// "next Friday is off" is exactly what somebody checking the calendar came to
|
||||
// find out.
|
||||
const upcoming = runs
|
||||
.filter((r) => !live.includes(r) && new Date(r.scheduled_for).getTime() >= now)
|
||||
.sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for))
|
||||
const past = runs.filter((r) => !live.includes(r) && !upcoming.includes(r)).slice(0, PAST_RUNS)
|
||||
|
||||
// `next` is narrower than `upcoming[0]`, deliberately: the headline answers
|
||||
// "when is the next one", and a cancelled occurrence is not one. An event
|
||||
// whose only future occurrence has been called off has no `next` and says so,
|
||||
// while the cancellation itself is still listed below.
|
||||
const next = upcoming.find((r) => r.status === 'scheduled') || null
|
||||
|
||||
// Which occurrence the results table is about. An explicit `run` wins; then a
|
||||
// live one, because that is what the visitor is looking at; then the most
|
||||
// recent that actually published results, because a page with a table on it is
|
||||
// more use than one with an empty heading.
|
||||
const named = runId ? runs.find((r) => String(r.id) === String(runId)) : null
|
||||
const resultsRun = named || live[0] || past.find((r) => r.results_published_at) || null
|
||||
|
||||
let participants = []
|
||||
if (resultsRun && resultsRun.results_published_at) {
|
||||
participants = (await participantsDb.listForRun(resultsRun.id, RESULTS_LIMIT)).map(
|
||||
publicParticipant,
|
||||
)
|
||||
}
|
||||
|
||||
// Phase labels come from the version the run is EXECUTING rather than from the
|
||||
// definition's working draft, which an author may be halfway through editing.
|
||||
// One extra read, and only when there is a run to label at all.
|
||||
const specRun = named || live[0] || null
|
||||
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
event: {
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
summary: definition.summary,
|
||||
body: definition.body,
|
||||
imageUrl: definition.image_url,
|
||||
timezone: definition.timezone,
|
||||
series: series ? { name: series.name, slug: series.slug } : null,
|
||||
live: live.length > 0,
|
||||
current: live[0] ? publicOccurrence(live[0], spec) : null,
|
||||
next: next ? publicOccurrence(next, spec) : null,
|
||||
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
|
||||
past: past.map((r) => publicOccurrence(r, spec)),
|
||||
results:
|
||||
resultsRun && resultsRun.results_published_at
|
||||
? {
|
||||
runId: resultsRun.id,
|
||||
scheduledFor: resultsRun.scheduled_for,
|
||||
publishedAt: resultsRun.results_published_at,
|
||||
participants,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One arc: the series, and the listed events in it in the order an editor
|
||||
* dragged them into.
|
||||
*
|
||||
* **A series with no listed events is a 404 rather than an empty page.** The arc
|
||||
* is a label on its definitions and nothing else, so a page for an empty one
|
||||
* would publish the single fact that an operator has named something they have
|
||||
* not announced.
|
||||
*/
|
||||
async function series(slug) {
|
||||
const row = await seriesDb.getBySlug(String(slug || ''))
|
||||
if (!row) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
const definitions = await definitionsDb.listPublicBySeries(row.id)
|
||||
if (!definitions.length) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
series: {
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
description: row.description,
|
||||
events: definitions.map((d) => ({
|
||||
title: d.title,
|
||||
slug: d.slug,
|
||||
summary: d.summary,
|
||||
imageUrl: d.image_url,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's participation history.
|
||||
*
|
||||
* Self-scoped by the caller's own id and nothing else. There is no route on
|
||||
* which one account reads another's, and deliberately no id parameter that could
|
||||
* later grow into one.
|
||||
*/
|
||||
async function history(userId, { limit = 50, before = null } = {}) {
|
||||
const rows = await participantsDb.listForUser(userId, { limit, before })
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
runId: r.run_id,
|
||||
title: r.definition_title,
|
||||
slug: r.definition_slug,
|
||||
seriesName: r.series_name || null,
|
||||
seriesSlug: r.series_slug || null,
|
||||
scheduledFor: r.scheduled_for,
|
||||
startedAt: r.started_at,
|
||||
endedAt: r.ended_at,
|
||||
timezone: r.timezone,
|
||||
status: publicStatus(r.status),
|
||||
joinedAt: r.joined_at,
|
||||
score: r.score,
|
||||
// Null until `core.results.publish` ran. The screen says so rather than
|
||||
// inventing a position nobody computed.
|
||||
rank: r.rank_at,
|
||||
resultsPublishedAt: r.results_published_at || null,
|
||||
meta: r.meta || null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calendar,
|
||||
event,
|
||||
series,
|
||||
history,
|
||||
publicStatus,
|
||||
phaseLabel,
|
||||
DEFAULT_WINDOW_DAYS,
|
||||
DEFAULT_RECENT_DAYS,
|
||||
PAST_RUNS,
|
||||
}
|
||||
134
server/src/model/events/eventRunBudget.db.js
Normal file
134
server/src/model/events/eventRunBudget.db.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── event_run_budget — SQL only ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one
|
||||
// dimension, and the most it may.
|
||||
//
|
||||
// **The whole file exists for one statement.** `spend()` is the conditional
|
||||
// increment §E names as the answer to "two steps spending one cap":
|
||||
//
|
||||
// UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap
|
||||
//
|
||||
// A read-then-write would let two steps drawing on `uo.creatures` in the same
|
||||
// tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second
|
||||
// one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction,
|
||||
// no lock, and no second opinion about the arithmetic. Same shape as the outbox
|
||||
// claim, `runsDb.transition` and Phase 5's gate increment, and the same argument.
|
||||
//
|
||||
// **The SET list here has one assignment for the reason Phase 5's had three.**
|
||||
// MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what
|
||||
// the ones before it assigned — which is how Phase 5's gate closed a firing early
|
||||
// — so nothing in this statement may read `consumed` after it has been written.
|
||||
// The guard is in the WHERE, where it reads the pre-update row, and it must stay
|
||||
// there.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Seed a run's budget rows.
|
||||
*
|
||||
* **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next
|
||||
* one cannot double-seed and cannot reset a cap a run has already spent against —
|
||||
* the idempotence `materialisePhase` and `gates.open` both have, for the same
|
||||
* reason.
|
||||
*
|
||||
* The caps are COPIED here rather than read live at dispatch. A run pins its
|
||||
* version and is reproducible in every other respect; a cap read live would be
|
||||
* the one input to a run's behaviour an admin could change underneath it while
|
||||
* nobody was watching, and the console's meter would answer "what is allowed now"
|
||||
* when the question afterwards is "what was this run allowed".
|
||||
*/
|
||||
async function seed(runId, dimensions) {
|
||||
const rows = Object.entries(dimensions || {})
|
||||
if (!rows.length) return 0
|
||||
const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ')
|
||||
const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null])
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from)
|
||||
VALUES ${values}`,
|
||||
params,
|
||||
)
|
||||
return result.affectedRows || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend `amount` of one dimension, or refuse.
|
||||
*
|
||||
* Answers `true` when the row moved and `false` when it did not — and `false` has
|
||||
* exactly two causes, both of which mean the same thing to the caller: the spend
|
||||
* would breach the cap, or there is no row for this dimension at all. The second
|
||||
* is not a silent pass: a run whose version names an action that costs a
|
||||
* dimension always has that dimension seeded — **uncapped ones included, as a row
|
||||
* with a NULL cap** — so a missing row means the step is spending something its
|
||||
* own version never declared, and refusing that is the fail-closed direction.
|
||||
*
|
||||
* A zero or negative amount is not a spend and never touches the database. An
|
||||
* action whose `cost()` answers `0` for its params is telling core it consumes
|
||||
* nothing, and pricing that as a query would put one round trip per step behind a
|
||||
* fact the caller already has.
|
||||
*/
|
||||
async function spend(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return true
|
||||
const result = await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`,
|
||||
[amount, runId, dimension, amount],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Give `amount` back.
|
||||
*
|
||||
* Called on exactly one path: a step that spent several dimensions and was then
|
||||
* refused on a later one. The spends are separate statements — they must be, the
|
||||
* atomicity that matters is per dimension — so a step costing 5 creatures and 2
|
||||
* bosses can take the creatures and be refused the bosses, and a step that did
|
||||
* not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the
|
||||
* floor is worth more than a refund that is exactly right: a negative `consumed`
|
||||
* would make the cap arithmetic lie in the permissive direction forever after.
|
||||
*/
|
||||
async function refund(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return
|
||||
await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = GREATEST(consumed - ?, 0)
|
||||
WHERE run_id = ? AND dimension = ?`,
|
||||
[amount, runId, dimension],
|
||||
)
|
||||
}
|
||||
|
||||
/** Every dimension of one run, for the console's meter. */
|
||||
async function forRun(runId) {
|
||||
return query(
|
||||
`SELECT dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget WHERE run_id = ? ORDER BY dimension`,
|
||||
[runId],
|
||||
)
|
||||
}
|
||||
|
||||
/** The dimensions of several runs at once, keyed by run id — the run LIST's read. */
|
||||
async function forRuns(runIds) {
|
||||
const ids = [...new Set(runIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT run_id, dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget
|
||||
WHERE run_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY run_id, dimension`,
|
||||
ids,
|
||||
)
|
||||
const out = new Map()
|
||||
for (const r of rows) {
|
||||
if (!out.has(r.run_id)) out.set(r.run_id, [])
|
||||
out.get(r.run_id).push({
|
||||
dimension: r.dimension,
|
||||
consumed: r.consumed,
|
||||
cap: r.cap,
|
||||
effective_from: r.effective_from,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { seed, spend, refund, forRun, forRuns }
|
||||
463
server/src/model/events/eventRunControls.model.js
Normal file
463
server/src/model/events/eventRunControls.model.js
Normal file
@@ -0,0 +1,463 @@
|
||||
// ── 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.
|
||||
//
|
||||
// **`advance` is the seventh, and it arrived in Phase 5 rather than Phase 3
|
||||
// because that is when it started meaning something.** A phase used to advance
|
||||
// when its steps went terminal and on nothing else, so "force it anyway" named
|
||||
// no state an operator could be in; a phase with a gate can wait for a boss that
|
||||
// will never spawn, and then it names exactly one. It is the other half of the
|
||||
// diagnosis panel: a screen that explains why a phase has not started, beside a
|
||||
// control that does something about it.
|
||||
//
|
||||
// **`cleanup` is the eighth, and Phase 8 is what gave it a ledger to work over.**
|
||||
// It re-runs the teardown across every resource a run has not given back, and it
|
||||
// is `admin` where the other seven are `admin` + `moderator`: it is not incident
|
||||
// response, it is asking core to write to the world again. Its partner is
|
||||
// cancel's new `cleanup: false`, which is §L's "cancelling WITHOUT cleanup is a
|
||||
// separate, logged, admin-only action" — deliberately the flag that has to be
|
||||
// asked for, because the safe default is to give back what the run took.
|
||||
//
|
||||
// **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 announce = require('../../events/announce')
|
||||
const gatesDb = require('./eventPhaseGates.db')
|
||||
const resourcesDb = require('./eventRunResources.db')
|
||||
const gates = require('../../events/gates')
|
||||
|
||||
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, cleanup = true } = {}, userId = null, { isAdmin = true } = {}) {
|
||||
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}`)
|
||||
|
||||
// §L: cancelling WITHOUT cleanup is a separate, logged, ADMIN-only action. The
|
||||
// route itself is `admin` + `moderator`, so the narrower gate cannot live in
|
||||
// middleware — which of the two you have to be depends on what is in the body,
|
||||
// exactly as the authoring role floor does (§K).
|
||||
if (cleanup === false && !isAdmin) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
errors: ['leaving a run\'s world changes in place is an administrator\'s decision'],
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
cleanup: cleanup !== false,
|
||||
},
|
||||
})
|
||||
|
||||
// **After the guarded transition, so exactly one caller announces** (Phase
|
||||
// 10). Two moderators pressing cancel in the same second both reach the log
|
||||
// write; only one of them wins `transition`, and the loser has already
|
||||
// returned a 409 above.
|
||||
//
|
||||
// The operator's `reason`, not the run's `last_error` — `cancel` takes a
|
||||
// sentence a human typed for other humans, and the diagnostic string that
|
||||
// ends up in `last_error` would read as gibberish in a mail.
|
||||
await announce.runCancelled(run, note)
|
||||
|
||||
// **The teardown is not done here, and the request does not wait for it.**
|
||||
// Cleanup is one leg of the runner's tick over terminal runs (§L), which is
|
||||
// what makes it survive a process that dies halfway through it — and a cancel
|
||||
// pressed at two in the morning must answer at once rather than after a dozen
|
||||
// round trips to a shard that may be the reason it is being cancelled. The run
|
||||
// is terminal the moment this returns, so the very next tick picks its ledger
|
||||
// up.
|
||||
//
|
||||
// `cleanup: false` is the operator saying leave it. The resources stay
|
||||
// unresolved and the run carries `incomplete`, which is the truthful value: the
|
||||
// world changes are still up, they are listed on the console, and the log line
|
||||
// above records who decided that.
|
||||
let cleanupStatus = run.cleanup_status
|
||||
if (cleanup === false && (await resourcesDb.unresolvedCount(run.id)) > 0) {
|
||||
// `incomplete` is what takes the run out of the cleanup leg's scan, and it is
|
||||
// the truthful value: the world changes are still up, they are listed on the
|
||||
// console, and the log line above records who decided that.
|
||||
//
|
||||
// **The first draft spent every row's `revert_attempts` instead**, to stop the
|
||||
// sweep by the same mechanism a failed retry does. It worked and it made the
|
||||
// console lie: the run page rendered "3 attempts" beside resources nothing had
|
||||
// ever tried, which reads as "core tried three times and could not". Found by
|
||||
// opening the page. A counter that means two things is a counter a screen
|
||||
// cannot render.
|
||||
await runsDb.setCleanupStatus(run.id, 'incomplete')
|
||||
cleanupStatus = 'incomplete'
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
run: await runsDb.getById(run.id),
|
||||
cancelledSteps: closed,
|
||||
cleanup: cleanup !== false,
|
||||
cleanupStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run cleanup over everything a run has not given back.
|
||||
*
|
||||
* The manual retry §L promises, and the only thing that clears
|
||||
* `revert_attempts`. That licence is the same one a human's step retry has, and
|
||||
* it is deliberately not extended to the automatic sweep: Engagement Phase 14's
|
||||
* defect was exactly a sweep that reset every stale row, which made the attempt
|
||||
* ceiling unreachable and left the row cycling for ever.
|
||||
*
|
||||
* Legal on a TERMINAL run only. A run still in flight has a ledger that is still
|
||||
* growing, and reverting a resource the next step is about to use would be core
|
||||
* undoing an event while it is happening.
|
||||
*/
|
||||
async function cleanupRun(runId, 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 still ${run.status}; cancel it before cleaning up after it`)
|
||||
}
|
||||
if (run.cleanup_status === 'not_required') {
|
||||
return conflict('this run recorded no resources, so there is nothing to give back')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line global-require
|
||||
const summary = await require('../../events/cleanup').cleanupRun(run, {
|
||||
resetAttempts: true,
|
||||
actor: userId,
|
||||
})
|
||||
return { ok: true, run: await runsDb.getById(run.id), summary }
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a phase forward: open its gate without the condition that would have.
|
||||
*
|
||||
* **It is legal only when the phase is actually waiting on a gate**, and the
|
||||
* three refusals are the whole design. A run that is not `running` is not
|
||||
* waiting on anything (409 naming what it is). A phase with no gate advances on
|
||||
* its steps and always has, so forcing it would be a control that duplicated the
|
||||
* runner rather than overriding it. And a phase whose steps have not all gone
|
||||
* terminal is not being held by its gate — it is being held by a step, and the
|
||||
* step-level skip is the honest control for that, one step at a time. A force
|
||||
* that swept past pending steps would be a cancel of half a phase under a button
|
||||
* labelled advance.
|
||||
*
|
||||
* **It satisfies the gate and stops.** The next tick advances the run, exactly
|
||||
* as it does after `resume` — the phase transition, the next phase's
|
||||
* materialisation, its own gate and the log lines are one sequence in
|
||||
* `advanceRun`, and a second copy of it here would be a second opinion about
|
||||
* what a phase boundary is. The response says which phase was released, so the
|
||||
* console can say so before the tick lands.
|
||||
*/
|
||||
async function advancePhase(runId, { reason } = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (run.status !== 'running') return conflict(`a ${run.status} run has no phase to advance`)
|
||||
if (!run.current_phase) return conflict('this run has not entered a phase yet')
|
||||
|
||||
const gate = await gatesDb.forPhase(run.id, run.current_phase)
|
||||
if (!gate) return conflict(`phase "${run.current_phase}" has no advance condition; skip its steps instead`)
|
||||
if (gate.satisfied_at) return conflict(`phase "${run.current_phase}" is already past its advance condition`)
|
||||
|
||||
const openStep = await stepsDb.nextOpenStep(run.id, run.current_phase)
|
||||
if (openStep) {
|
||||
return conflict(
|
||||
`phase "${run.current_phase}" is waiting on step ${openStep.seq} (${openStep.action_id}), not on its advance condition`,
|
||||
)
|
||||
}
|
||||
|
||||
const note = clean(reason)
|
||||
if (!(await gatesDb.satisfy(gate.id, 'forced', { userId }))) {
|
||||
return conflict('this phase stopped waiting on its advance condition')
|
||||
}
|
||||
|
||||
const described = gates.describe(gate)
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'phase.advanced',
|
||||
phase: run.current_phase,
|
||||
detail: {
|
||||
because: 'forced',
|
||||
control: 'advance',
|
||||
by: userId,
|
||||
reason: note,
|
||||
waitedSeconds: described.elapsedSeconds,
|
||||
...(gate.kind === 'on'
|
||||
? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed }
|
||||
: { after: gate.after_seconds }),
|
||||
},
|
||||
})
|
||||
return { ok: true, run: await runsDb.getById(run.id), phase: run.current_phase }
|
||||
}
|
||||
|
||||
// ── 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, cleanupRun, advancePhase, confirmStep, skipStep, retryStep }
|
||||
144
server/src/model/events/eventRunLog.db.js
Normal file
144
server/src/model/events/eventRunLog.db.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// ── event_run_log — SQL only ───────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md § Observability. "Why didn't phase 3 start?" must be a query, and
|
||||
// `activity_log.detail` is TEXT and unqueryable, which is why this table exists
|
||||
// beside the audit log rather than instead of it. Both are written: the audit of
|
||||
// WHO published WHAT goes to `activity_log`, the diagnosis goes here.
|
||||
//
|
||||
// **`kind` is a closed set enforced here rather than an ENUM in the DDL.** The
|
||||
// set grows with almost every later phase — conditions in Phase 5, cap draws in
|
||||
// Phase 6, ledger movements in Phase 8 — and an ENUM change is a table alter
|
||||
// this project has no migration system for. A constant in a file is the same
|
||||
// guarantee with a cheaper hinge.
|
||||
|
||||
const log = require('../../utils/logger')('events')
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
// Phase 1's kinds. Later phases append; nothing here is ever renamed, because a
|
||||
// stored row would then name a kind no reader knows.
|
||||
const KINDS = [
|
||||
'run.created', // an occurrence was materialised
|
||||
'run.status', // a status transition, with from/to
|
||||
'phase.entered', // a phase's steps were materialised
|
||||
'step.status', // a step transition, with the module's answer
|
||||
'note', // a human action taken from the admin surface
|
||||
// Phase 2's, all five of them answers to a question an operator asks out
|
||||
// loud. `run.blocked` in particular is the whole reason this table exists
|
||||
// rather than a server log line: "it did not start because run 37 holds
|
||||
// invasion:Yew" is a fact with two run ids in it, and it has to be
|
||||
// queryable from the run that did NOT start.
|
||||
'run.blocked', // an occurrence held off: another run has its concurrency key
|
||||
'run.health', // a health change, which is not a status change
|
||||
'step.retry', // a step failed transiently and will be attempted again
|
||||
'step.parked', // a step is waiting on a human and nothing is holding it
|
||||
'phase.completed', // every step of a phase reached a terminal status
|
||||
// Phase 5's four. `condition.evaluated` is written for BOTH outcomes (§
|
||||
// Observability), and the non-matching one is the more valuable of the two on
|
||||
// the night: "the boss did spawn, in Britain" and "no boss has spawned" are
|
||||
// different answers to the same question and look identical without it.
|
||||
'phase.gate', // a phase opened an advance gate, with what it waits for
|
||||
'condition.evaluated', // a firing was tested against a gate, matched or not
|
||||
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
|
||||
// Phase 6's three. `step.refused` is the one worth naming separately from
|
||||
// `step.status`: a refusal is not a failure, and an operator reading a run that
|
||||
// stopped needs to see at a glance that nothing is broken -- the deployment
|
||||
// simply does not permit what the author asked for.
|
||||
'run.budget', // the caps this run was seeded with, and which switch set each
|
||||
'step.refused', // a step was not permitted: disabled, or over a cap
|
||||
'version.verified', // a dry run passed against a version, unlocking scheduled starts
|
||||
// Phase 8's six, and every one of them is an answer to "what did this event
|
||||
// leave behind". `resource.recorded` is written at the ANSWER rather than at
|
||||
// the placeholder, because a placeholder is a promise and the operator's
|
||||
// question is about the world.
|
||||
'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
|
||||
'resource.orphaned', // a module reports a ledgered resource is no longer in force
|
||||
'cleanup.reverted', // a group of resources came back
|
||||
'cleanup.failed', // a group did not, with the reason and how it was left
|
||||
'cleanup.swept', // one pass over a run's ledger, and what it found
|
||||
'cleanup.retry', // a human cleared the attempt counter and asked again
|
||||
// Phase 10's four: the integrations. `announcement.emitted` is a line about
|
||||
// what the run SAID happened, not about who was told -- the engagement engine
|
||||
// owns that decision and logs its own, and a run log that claimed to know how
|
||||
// many mails went out would be reporting a decision it does not make.
|
||||
'participants.recorded', // a step reported who took part, and they are recorded
|
||||
'results.published', // the results table was ranked and stamped
|
||||
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
|
||||
'announcement.enqueued', // a post was linked to this run and queued on the legs
|
||||
// Phase 15's one, and it is the only kind whose payload core does not compose.
|
||||
// A module may answer a success envelope with a `detail` object; it is bounded
|
||||
// and sanitised at the dispatcher and written here verbatim beside the action
|
||||
// id. Nothing reads a key out of it — it exists because a module knows things
|
||||
// about its own verb that core cannot compute and had no other way to say.
|
||||
'step.detail', // a module's own account of what a successful step did
|
||||
]
|
||||
|
||||
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
||||
|
||||
const listForRun = async (runId, { limit = 500 } = {}) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 2000)
|
||||
return (
|
||||
await query(`SELECT * FROM event_run_log WHERE run_id = ? ORDER BY at DESC, id DESC LIMIT ${n}`, [
|
||||
runId,
|
||||
])
|
||||
).map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one line. **Never throws.**
|
||||
*
|
||||
* The diagnostic log is what an operator reads when something has already gone
|
||||
* wrong, so a failure to write it must not become a second failure on top of the
|
||||
* first — a runner that aborted a run because it could not record why would be
|
||||
* the worst possible reading of "observability". The same posture
|
||||
* `uoLinkClient.js` takes: answer, do not throw.
|
||||
*/
|
||||
async function write({ runId, stepId = null, kind, phase = null, detail = null }) {
|
||||
if (!KINDS.includes(kind)) {
|
||||
// A programming error, not an operational one, and it is louder than a
|
||||
// silent drop for exactly that reason.
|
||||
log.warn('event run log: unknown kind', { kind, runId })
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await query(
|
||||
'INSERT INTO event_run_log (run_id, step_id, kind, phase, detail) VALUES (?, ?, ?, ?, ?)',
|
||||
[runId, stepId, kind, phase, detail === null ? null : JSON.stringify(detail)],
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error('event run log write failed', { runId, kind, message: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete log lines belonging to runs that are both TERMINAL and older than
|
||||
* `before`, a bounded number at a time.
|
||||
*
|
||||
* The schema comment beside `idx_evlog_at` parked this sweep here, and it is the
|
||||
* rule Engagement Phase 14 arrived at applied to a second high-cardinality table:
|
||||
* **only terminal rows are eligible.** A run still in flight keeps every line it
|
||||
* has, however old — the log's whole job is answering "why didn't phase 3 start?"
|
||||
* about a run that is, right now, not starting phase 3, and a horizon that could
|
||||
* reach a live run would delete the answer while the question was still open.
|
||||
*
|
||||
* `LIMIT` makes one call a bounded amount of work rather than a table-sized
|
||||
* transaction; the timer runs again and takes the next slice. The join is on the
|
||||
* run's terminal status rather than on a precomputed id list so that a run which
|
||||
* reached a terminal state between the two would not be missed.
|
||||
*/
|
||||
const pruneTerminal = async (before, limit = 5000) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 5000, 1), 50_000)
|
||||
const result = await query(
|
||||
`DELETE l FROM event_run_log l
|
||||
JOIN event_runs r ON r.id = l.run_id
|
||||
WHERE r.status IN ('completed','cancelled','failed','missed')
|
||||
AND COALESCE(r.ended_at, r.updated_at) < ?
|
||||
LIMIT ${n}`,
|
||||
[before],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { KINDS, listForRun, write, pruneTerminal }
|
||||
165
server/src/model/events/eventRunParticipants.db.js
Normal file
165
server/src/model/events/eventRunParticipants.db.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// ── event_run_participants — SQL only ──────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of
|
||||
// §D's core tables: who took part in a run, and how well.
|
||||
//
|
||||
// **Core writes this table and never sources it.** A `member_key` is
|
||||
// module-opaque, exactly as a resource's `ref` is — core cannot map a character
|
||||
// name onto a user row and must not try, because that mapping is one game's
|
||||
// (`shard_links`, for module-uo) and would be that game compiled into core. A
|
||||
// module that knows both halves reports both; core stores what it is told.
|
||||
//
|
||||
// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step
|
||||
// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried
|
||||
// collect that duplicated its rows would double a leaderboard. It is the same
|
||||
// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table
|
||||
// along, with the difference that a re-report may carry a BETTER score and must
|
||||
// win rather than be ignored.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already
|
||||
// arrives as a JS number; the coercion is belt to that braces and costs nothing.
|
||||
// `meta` is hydrated for the reason a resource's payload is: opaque to core, but
|
||||
// every caller wants the object rather than the string the driver returns.
|
||||
const hydrate = (row) =>
|
||||
row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) }
|
||||
|
||||
/**
|
||||
* Record one participant, or update the one already recorded.
|
||||
*
|
||||
* **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is
|
||||
* the point of the column: it is when this participant first appeared, and a
|
||||
* second report — a later collect, a corrected score — must not rewrite it. The
|
||||
* same applies to `rank_at`, which is not touched here at all: ranking is
|
||||
* `core.results.publish`'s job and a re-report between two publications must not
|
||||
* silently invent a rank nobody computed.
|
||||
*
|
||||
* `user_id` DOES move on a re-report, deliberately: a player who linked their
|
||||
* website account between two collects should stop being anonymous, and the
|
||||
* module is the only thing that can know they did.
|
||||
*
|
||||
* **It answers nothing, and the reason is a trap worth naming.** The obvious
|
||||
* return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an
|
||||
* update. That is true only without `CLIENT_FOUND_ROWS`, and this connector
|
||||
* sends it: with it, a re-report whose values are identical also answers 1, so
|
||||
* the flag would report every idempotent retry as a fresh participant. The
|
||||
* caller wants "how many were reported" anyway, which it already knows from the
|
||||
* length of its own list.
|
||||
*/
|
||||
async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) {
|
||||
await query(
|
||||
`INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
score = VALUES(score),
|
||||
meta = VALUES(meta)`,
|
||||
[runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt],
|
||||
)
|
||||
}
|
||||
|
||||
/** One run's participants, best first. The results table, and the console's. */
|
||||
async function listForRun(runId, limit = 500) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_participants
|
||||
WHERE run_id = ?
|
||||
ORDER BY score DESC, joined_at ASC, id ASC
|
||||
LIMIT ?`,
|
||||
[runId, limit],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's participation history, most recent event first (Phase 14a).
|
||||
*
|
||||
* **Joined all the way out to the definition, and the join is the access
|
||||
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
|
||||
* definition is excluded because unlisting is what an operator does to an event
|
||||
* they are not announcing — a history that named it would announce it to
|
||||
* everyone who attended, which is everyone who could tell anybody.
|
||||
*
|
||||
* `member_key` is NOT selected. It is the game's identifier for a character and
|
||||
* the caller is a player reading their own page; the run, the date, the score
|
||||
* and the rank are what a history is, and the key adds a module-opaque string
|
||||
* nothing on the page can render.
|
||||
*
|
||||
* `rank_at` is null until results are published, and that is a real state the
|
||||
* screen shows rather than an error — a run whose participants are collected
|
||||
* and unranked is exactly what Phase 10 made visible on the admin side.
|
||||
*/
|
||||
async function listForUser(userId, { limit = 50, before = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const args = [userId]
|
||||
// A keyset cursor on the participation row rather than an offset: the list
|
||||
// gains a row every time the reader attends something, and an offset page two
|
||||
// would skip whatever arrived in between.
|
||||
const cursor = before ? ' AND p.id < ?' : ''
|
||||
if (before) args.push(before)
|
||||
const rows = await query(
|
||||
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
|
||||
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
|
||||
r.timezone, r.results_published_at,
|
||||
d.title AS definition_title, d.slug AS definition_slug,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_run_participants p
|
||||
JOIN event_runs r ON r.id = p.run_id
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE p.user_id = ?${cursor}
|
||||
AND r.rehearsal = 0
|
||||
AND d.listed = 1
|
||||
ORDER BY p.id DESC
|
||||
LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** How many the run has. Its own query because the trigger payload needs only this. */
|
||||
async function countForRun(runId) {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Number every participant of one run by score, best first.
|
||||
*
|
||||
* **One statement, and it has to be one.** The obvious form — `SET @rk := 0`
|
||||
* followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is
|
||||
* wrong here in a way that would have passed every test that did not run twice
|
||||
* concurrently: `query()` takes a connection from the pool per call and releases
|
||||
* it, so the session variable is set on one connection and read on whichever the
|
||||
* second call happens to get. A window function needs no session state at all.
|
||||
*
|
||||
* **The ordering is total.** `score DESC` alone leaves ties in whatever order the
|
||||
* engine felt like, so two publications of the same run would hand out different
|
||||
* ranks to the same two people; `joined_at` then `id` breaks every tie the same
|
||||
* way every time, which is what makes re-publishing idempotent rather than a
|
||||
* reshuffle.
|
||||
*
|
||||
* Ties share nothing — two people on the same score get consecutive ranks rather
|
||||
* than a dense or competition ranking. That is a presentation decision belonging
|
||||
* to whatever renders the table; what this owes is a stable number.
|
||||
*/
|
||||
async function rankRun(runId) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_participants p
|
||||
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk
|
||||
FROM event_run_participants
|
||||
WHERE run_id = ?) r ON r.id = p.id
|
||||
SET p.rank_at = r.rk`,
|
||||
[runId],
|
||||
)
|
||||
// The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather
|
||||
// than rows changed — which is the number wanted here. Re-publishing a run
|
||||
// whose ranks are already correct answers "12 ranked", not "0".
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, listForRun, listForUser, countForRun, rankRun }
|
||||
389
server/src/model/events/eventRunResources.db.js
Normal file
389
server/src/model/events/eventRunResources.db.js
Normal file
@@ -0,0 +1,389 @@
|
||||
// ── event_run_resources — SQL only ─────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §L ("The ledger's two rules"), and Phase 8 of EVENTS_PLAN.md.
|
||||
// Everything one run created or leased, and what became of it.
|
||||
//
|
||||
// **Rule 1 lives in `reserve()`.** A resource is recorded BEFORE it is
|
||||
// confirmed, so the placeholder this writes is the row that exists while the
|
||||
// dispatch is in flight — and the row that SURVIVES when the acknowledgement is
|
||||
// lost. Recording on the answer instead would make every object whose ack went
|
||||
// missing invisible to cleanup for ever.
|
||||
//
|
||||
// **Rule 2 lives in the status column and in `failRevert()`.** A revert that
|
||||
// never succeeds leaves its row unreverted, with the error on it, and the run
|
||||
// completes with `cleanup_status = 'incomplete'` rather than being held open.
|
||||
// Loud and sticky.
|
||||
//
|
||||
// **The unique key is enforced by the database, not by a read.** `reserve()`
|
||||
// answers `{ ok: false, code: 'held' }` on a duplicate key rather than checking
|
||||
// first and then inserting — two runs entering the same tick would both pass the
|
||||
// check. It is the argument `event_run_budget.spend()` makes about the cap and
|
||||
// `runsDb.transition` makes about a status, in the third place it applies.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
// The one `kind` core owns. A module's kinds are opaque and stored verbatim; this
|
||||
// one is core's own, and `registries` refuses a module resource that claims it.
|
||||
const STEP_KIND = '@step'
|
||||
|
||||
// The statuses that mean "core still believes this resource is this run's". They
|
||||
// are exactly the ones the `live_marker` generated column keeps non-NULL, so the
|
||||
// unique target key holds while a row is in one of them and releases when it
|
||||
// leaves. Duplicated here as a JavaScript list because the sweeps read by it too,
|
||||
// and a second copy that can drift is better than a query that cannot express it.
|
||||
const HELD = ['pending', 'confirmed', 'reverting']
|
||||
|
||||
// Every status that still wants a human or a retry: `HELD` plus the two that mean
|
||||
// "we let go, and not cleanly". This is what "unreverted" means everywhere in
|
||||
// this feature — the console's list, `cleanup_status`, and the manual retry.
|
||||
const UNRESOLVED = [...HELD, 'orphaned', 'drifted']
|
||||
|
||||
const COLUMNS = `id, run_id, step_id, owner_module, kind, ref, payload, lease_until,
|
||||
status, revert_attempts, last_error, member_key, created_at, updated_at`
|
||||
|
||||
// `payload` is opaque to core and stored verbatim, but it comes back as a string
|
||||
// from the driver and every caller wants the object — the cleanup sweep reads a
|
||||
// lease's baseline out of it, and the console renders it. Hydrated here for the
|
||||
// same reason a step's params are: one place rather than at each read.
|
||||
const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, null) }
|
||||
|
||||
/**
|
||||
* Record a resource that does not exist yet.
|
||||
*
|
||||
* Answers `{ ok: true, id }`, or `{ ok: false, code: 'held', holder }` when the
|
||||
* target is already someone's — which is the lease conflict, surfaced as a
|
||||
* refusal rather than a failure because nothing is wrong with the system: another
|
||||
* run has the thing.
|
||||
*
|
||||
* **`ER_DUP_ENTRY` is the check.** The holder is looked up only to name it in the
|
||||
* refusal, and only after the insert has already lost the race.
|
||||
*/
|
||||
async function reserve({ runId, stepId = null, owner, kind, ref, payload = null, leaseUntil = null, memberKey = null }) {
|
||||
try {
|
||||
const result = await query(
|
||||
`INSERT INTO event_run_resources
|
||||
(run_id, step_id, owner_module, kind, ref, payload, lease_until, member_key, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
|
||||
[runId, stepId, owner, kind, ref, payload === null ? null : JSON.stringify(payload), leaseUntil, memberKey],
|
||||
)
|
||||
return { ok: true, id: Number(result.insertId) }
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
||||
const [holder] = await query(
|
||||
`SELECT run_id, status FROM event_run_resources
|
||||
WHERE owner_module = ? AND kind = ? AND ref = ? AND status IN (?, ?, ?)
|
||||
LIMIT 1`,
|
||||
[owner, kind, ref, ...HELD],
|
||||
)
|
||||
return { ok: false, code: 'held', holder: holder || null }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a reserved row to `confirmed`, optionally attaching what the module
|
||||
* finally said about it.
|
||||
*
|
||||
* Guarded on `pending` so a late answer cannot un-revert a row cleanup has
|
||||
* already dealt with — the same reason every other write in this feature is a
|
||||
* compare-and-set rather than a read-then-write.
|
||||
*/
|
||||
async function confirm(id, { payload, leaseUntil, memberKey } = {}) {
|
||||
const sets = ["status = 'confirmed'"]
|
||||
const params = []
|
||||
if (payload !== undefined) {
|
||||
sets.push('payload = ?')
|
||||
params.push(payload === null ? null : JSON.stringify(payload))
|
||||
}
|
||||
if (leaseUntil !== undefined) {
|
||||
sets.push('lease_until = ?')
|
||||
params.push(leaseUntil)
|
||||
}
|
||||
if (memberKey !== undefined) {
|
||||
sets.push('member_key = ?')
|
||||
params.push(memberKey)
|
||||
}
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources SET ${sets.join(', ')} WHERE id = ? AND status = 'pending'`,
|
||||
[...params, id],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a step placeholder once the module has named what it actually made.
|
||||
*
|
||||
* The placeholder's whole job is over at this point: the real rows exist, so the
|
||||
* `@step` row must stop being one of the things cleanup will try to revert.
|
||||
* `reverted` is the honest terminal state for it — there is nothing left to undo
|
||||
* that the rows it stood in for do not now cover — and it releases the
|
||||
* idempotency key for a later run, which matters because keys are per step and a
|
||||
* re-materialised step reuses its own.
|
||||
*/
|
||||
async function resolvePlaceholder(id) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = 'reverted', last_error = NULL
|
||||
WHERE id = ? AND kind = ? AND status IN ('pending', 'confirmed')`,
|
||||
[id, STEP_KIND],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One row by its target, live or not — how a caller that lost the insert race
|
||||
* finds the row it meant to write. Newest first, so a target that has been held
|
||||
* and released several times answers with the current holder.
|
||||
*/
|
||||
async function findByTarget(owner, kind, ref) {
|
||||
const [row] = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources
|
||||
WHERE owner_module = ? AND kind = ? AND ref = ?
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[owner, kind, ref],
|
||||
)
|
||||
return hydrate(row) || null
|
||||
}
|
||||
|
||||
/** One run's whole ledger, oldest first — the console's read. */
|
||||
async function forRun(runId) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources WHERE run_id = ? ORDER BY id`,
|
||||
[runId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** The rows of one run that still want something: the cleanup sweep's input. */
|
||||
async function unresolvedForRun(runId, { maxAttempts = null } = {}) {
|
||||
const params = [runId, ...UNRESOLVED]
|
||||
const attemptClause = maxAttempts === null ? '' : ' AND revert_attempts < ?'
|
||||
if (maxAttempts !== null) params.push(maxAttempts)
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources
|
||||
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)${attemptClause}
|
||||
ORDER BY id`,
|
||||
params,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** How many of one run's rows are still unresolved — what `cleanup_status` is derived from. */
|
||||
async function unresolvedCount(runId) {
|
||||
const [row] = await query(
|
||||
`SELECT COUNT(*) AS n FROM event_run_resources
|
||||
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
|
||||
[runId, ...UNRESOLVED],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/** Unresolved counts for several runs at once, keyed by run id — the run LIST's read. */
|
||||
async function unresolvedCounts(runIds) {
|
||||
const ids = [...new Set(runIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT run_id, COUNT(*) AS n FROM event_run_resources
|
||||
WHERE run_id IN (${ids.map(() => '?').join(',')}) AND status IN (?, ?, ?, ?, ?)
|
||||
GROUP BY run_id`,
|
||||
[...ids, ...UNRESOLVED],
|
||||
)
|
||||
return new Map(rows.map((r) => [r.run_id, Number(r.n)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`,
|
||||
* and `reverting` again once the claim on it has gone stale.
|
||||
*
|
||||
* The compare-and-set that keeps the cleanup leg and the manual cleanup route off
|
||||
* each other's rows. A row another pass is mid-revert on is left alone, exactly as
|
||||
* a step with a live claim is.
|
||||
*
|
||||
* **"Exactly as a step" has to include the expiry, and it did not until the Phase
|
||||
* 16 acceptance walk.** A step's claim carries `claim_expires_at`, so a step whose
|
||||
* process died is reclaimed once the lease lapses — that reclaim is the whole
|
||||
* reason §E's CAS survives §N4's single instance. A `reverting` row had no such
|
||||
* bound and nothing released it, so a process killed mid-teardown stranded the row
|
||||
* for good: the sweep skipped it every 15s forever, `cleanup_status` never left
|
||||
* `pending`, and `POST …/cleanup` — the recourse §I names — answered 200 and did
|
||||
* nothing, because it claims through this same function. Observed with a lease,
|
||||
* which then blocked the NEXT run of the same event from taking the value.
|
||||
*
|
||||
* The stale test is `updated_at`, not a new column: the row is stamped exactly
|
||||
* when it enters `reverting` and is not written again until the revert resolves,
|
||||
* so for a `reverting` row `updated_at` IS "when this claim was taken". The bound
|
||||
* is the run lease's, for the run lease's reason — it has to outlast a whole
|
||||
* tick's work on one run, and every revert in a sweep is bounded by its action's
|
||||
* own `budgetMs` long before this.
|
||||
*
|
||||
* `revert_attempts` is deliberately NOT incremented by reclaiming. A stale claim
|
||||
* is a process that died, not an attempt that failed, and counting it would burn
|
||||
* the retry budget on crashes — Engagement Phase 14's rule, one table over.
|
||||
*
|
||||
* **`updated_at` is re-stamped explicitly, and that is what keeps this a CAS.**
|
||||
* This connector sends `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows MATCHED
|
||||
* rather than changed. For the four fresh statuses that is harmless — the winner
|
||||
* moves the row to `reverting` and the loser's `status IN (…)` no longer matches.
|
||||
* A stale `reverting` row has no such natural change: without re-stamping, the
|
||||
* row would still satisfy `status = 'reverting' AND updated_at < …` and a second
|
||||
* claimer would match it too. Writing the column is what makes the second one
|
||||
* miss.
|
||||
*/
|
||||
const REVERT_CLAIM_TTL_MS = Number(process.env.EVENT_REVERT_CLAIM_TTL_MS) || 15 * 60 * 1000
|
||||
|
||||
async function claimRevert(id) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = 'reverting', updated_at = NOW()
|
||||
WHERE id = ?
|
||||
AND (status IN ('pending', 'confirmed', 'orphaned', 'drifted')
|
||||
OR (status = 'reverting'
|
||||
AND updated_at < (NOW() - INTERVAL ? MICROSECOND)))`,
|
||||
[id, REVERT_CLAIM_TTL_MS * 1000],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/** The revert worked. `reverted` is terminal and releases the target. */
|
||||
async function markReverted(id) {
|
||||
await query(
|
||||
`UPDATE event_run_resources SET status = 'reverted', last_error = NULL WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The revert did not work, and the row goes back to being unresolved.
|
||||
*
|
||||
* `revert_attempts` is incremented here and NOWHERE else, and it is never reset by
|
||||
* a sweep — Engagement Phase 14's rule, whose defect was a reclaim that returned
|
||||
* every stale row to its start state and made the attempt ceiling unreachable, so
|
||||
* the row cycled for ever and was never eligible for any retention sweep. The one
|
||||
* thing that may reset it is a human pressing cleanup, which is the same licence
|
||||
* a human's step retry has.
|
||||
*
|
||||
* `restoreTo` is where the row lands: `drifted` when the module says somebody else
|
||||
* moved the value, `orphaned` when it says the thing is gone, and `confirmed`
|
||||
* otherwise — still ours, still out there, try again.
|
||||
*/
|
||||
async function failRevert(id, error, restoreTo = 'confirmed') {
|
||||
await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = ?, revert_attempts = revert_attempts + 1, last_error = ?
|
||||
WHERE id = ?`,
|
||||
[restoreTo, String(error || 'the revert did not answer').slice(0, 500), id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A human is trying again: clear the attempt counter on one run's unresolved rows.
|
||||
*
|
||||
* Only ever called from the cleanup route with an actor behind it. The automatic
|
||||
* leg must never do this (see `failRevert`).
|
||||
*/
|
||||
async function resetAttempts(runId) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources
|
||||
SET revert_attempts = 0
|
||||
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
|
||||
[runId, ...UNRESOLVED],
|
||||
)
|
||||
return result.affectedRows || 0
|
||||
}
|
||||
|
||||
/** Every live row one module owns, for the reconcile sweep. */
|
||||
async function liveForModule(owner, { limit = 500 } = {}) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources
|
||||
WHERE owner_module = ? AND status IN ('pending', 'confirmed')
|
||||
ORDER BY id LIMIT ?`,
|
||||
[owner, Number(limit)],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** Every module that currently owns a live row — who the reconcile sweep asks. */
|
||||
async function modulesWithLiveRows() {
|
||||
const rows = await query(
|
||||
`SELECT DISTINCT owner_module FROM event_run_resources
|
||||
WHERE status IN ('pending', 'confirmed')`,
|
||||
)
|
||||
return rows.map((r) => r.owner_module)
|
||||
}
|
||||
|
||||
/**
|
||||
* The game no longer has it. Never reached by a revert — a revert that finds
|
||||
* nothing there is a SUCCESS (§L, and it is what a Rust wipe needs) — only by
|
||||
* reconcile, which is a different question: nobody asked for this to go.
|
||||
*/
|
||||
async function markOrphaned(id, detail = null) {
|
||||
await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = 'orphaned', last_error = ?
|
||||
WHERE id = ? AND status IN ('pending', 'confirmed', 'reverting')`,
|
||||
[detail === null ? null : String(detail).slice(0, 500), id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal runs that still owe the world something — the cleanup leg's scan.
|
||||
*
|
||||
* **Both halves of the WHERE were live-walk findings, and they are opposite
|
||||
* mistakes.**
|
||||
*
|
||||
* `cleanup_status = 'pending'` alone missed a run whose only resource was a
|
||||
* LEASE: `core.lease` reserves its own row and never goes through the ledger's
|
||||
* `markRunDirty`, so the flag stayed `not_required` and the lease was never given
|
||||
* back at all. Hence `not_required` is in the list — a terminal run with an
|
||||
* unresolved row has something to do whatever any summary column says, and
|
||||
* treating that combination as work is the fail-safe direction.
|
||||
*
|
||||
* And the run status filter alone made `MAX_REVERT_ATTEMPTS` mean ONE attempt,
|
||||
* because the first failing sweep set `incomplete` and nothing looked at the run
|
||||
* again. That is fixed in `cleanupRun`, which now only writes `incomplete` once
|
||||
* there is nothing left it will try — so `incomplete` genuinely means "finished
|
||||
* with, and not finished", which is exactly what excludes both a run whose
|
||||
* retries are spent and a run an admin cancelled without cleanup.
|
||||
*
|
||||
* The attempt bound is in the join for a different reason: without it a run whose
|
||||
* rows are all spent would be selected, worked over and found to have nothing to
|
||||
* do on every tick for the rest of its life.
|
||||
*/
|
||||
async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
|
||||
return query(
|
||||
`SELECT DISTINCT r.id, r.status, r.cleanup_status, r.version_id, r.definition_id, r.scope
|
||||
FROM event_runs r
|
||||
JOIN event_run_resources res ON res.run_id = r.id
|
||||
WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
|
||||
AND r.cleanup_status IN ('pending', 'not_required')
|
||||
AND res.status IN (?, ?, ?, ?, ?)
|
||||
AND res.revert_attempts < ?
|
||||
ORDER BY r.id
|
||||
LIMIT ?`,
|
||||
[...UNRESOLVED, Number(maxAttempts), Number(limit)],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STEP_KIND,
|
||||
HELD,
|
||||
UNRESOLVED,
|
||||
reserve,
|
||||
confirm,
|
||||
resolvePlaceholder,
|
||||
findByTarget,
|
||||
forRun,
|
||||
unresolvedForRun,
|
||||
unresolvedCount,
|
||||
unresolvedCounts,
|
||||
claimRevert,
|
||||
markReverted,
|
||||
failRevert,
|
||||
resetAttempts,
|
||||
liveForModule,
|
||||
modulesWithLiveRows,
|
||||
markOrphaned,
|
||||
runsNeedingCleanup,
|
||||
}
|
||||
421
server/src/model/events/eventRunSteps.db.js
Normal file
421
server/src/model/events/eventRunSteps.db.js
Normal file
@@ -0,0 +1,421 @@
|
||||
// ── event_run_steps — SQL only ─────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §E. Phase 1 materialises a run's steps and reads them back
|
||||
// for the run console. **Draining them is Phase 2's**: the CAS claim, the lease,
|
||||
// the attempt counter and the classification of a module's answer are the
|
||||
// runner, and none of them is stubbed here.
|
||||
//
|
||||
// The one runtime property Phase 1 does have to get right is the idempotency key
|
||||
// (§E). Core mints it ONCE, at materialisation, and it does NOT vary by attempt —
|
||||
// a retry re-sends the same key so the game side can recognise the repeat. That
|
||||
// makes it a property of the INSERT below rather than of the dispatch, which is
|
||||
// the only reason it can be stable at all.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, params: parseJson(row.params, {}) }
|
||||
|
||||
/**
|
||||
* `sha256(runId | stepId)`, truncated to 40 hex — the shape `shardEvents.dedupeKey`
|
||||
* already uses, so the two dedupe keys on this codebase read alike.
|
||||
*
|
||||
* The step id is not known until the row exists, so materialisation inserts with
|
||||
* a provisional key and stamps the real one immediately afterwards. That is one
|
||||
* extra statement per step and it buys the property the whole retry story rests
|
||||
* on: the key is a function of identity, never of attempt or of clock.
|
||||
*/
|
||||
const idempotencyKey = (runId, stepId) =>
|
||||
crypto.createHash('sha256').update(`${runId}|${stepId}`).digest('hex').slice(0, 40)
|
||||
|
||||
const listForRun = async (runId) =>
|
||||
(
|
||||
await query(
|
||||
'SELECT * FROM event_run_steps WHERE run_id = ? ORDER BY phase, seq, id',
|
||||
[runId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM event_run_steps WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise one phase's steps.
|
||||
*
|
||||
* `INSERT IGNORE` against `UNIQUE (run_id, phase, seq)`, so a tick that overran
|
||||
* into the next one cannot double-materialise a phase — the same argument the
|
||||
* occurrence key makes one table up, at the other end of the run.
|
||||
*
|
||||
* Returns the rows as they now stand, created or pre-existing, so a caller that
|
||||
* lost the race still gets the step ids.
|
||||
*/
|
||||
const materialisePhase = async (runId, phase, steps) => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_steps
|
||||
(run_id, phase, seq, action_id, params, action_version, on_failure, idempotency_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
|
||||
[
|
||||
runId,
|
||||
phase,
|
||||
i,
|
||||
step.actionId,
|
||||
JSON.stringify(step.params || {}),
|
||||
step.actionVersion || 1,
|
||||
step.onFailure || 'pause',
|
||||
],
|
||||
)
|
||||
if (Number(result?.affectedRows || 0) === 1) {
|
||||
// Stamped in a second statement because the key is a function of the row's
|
||||
// own id. Scoped by the empty key so a re-run of this loop over an existing
|
||||
// phase can never overwrite a key a dispatch has already sent.
|
||||
await query(
|
||||
"UPDATE event_run_steps SET idempotency_key = ? WHERE id = ? AND idempotency_key = ''",
|
||||
[idempotencyKey(runId, result.insertId), result.insertId],
|
||||
)
|
||||
}
|
||||
}
|
||||
return listForRun(runId)
|
||||
}
|
||||
|
||||
/** The run console's summary line: how many steps sit in each status. */
|
||||
const statusCounts = async (runId) => {
|
||||
const rows = await query(
|
||||
'SELECT status, COUNT(*) AS n FROM event_run_steps WHERE run_id = ? GROUP BY status',
|
||||
[runId],
|
||||
)
|
||||
return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)]))
|
||||
}
|
||||
|
||||
// ── Phase 2: draining a step ───────────────────────────────────────────────
|
||||
//
|
||||
// The CAS claim, the lease, the attempt counter and the terminal writes. Phase 1
|
||||
// left all of it out rather than stubbing it, and this is where it lands.
|
||||
//
|
||||
// **Two rules govern everything below, and both were paid for once already.**
|
||||
//
|
||||
// 1. `attempts` is incremented by the CLAIM and by nothing else, and no recovery
|
||||
// path ever resets it. Engagement Phase 14's defect was a stale-row sweep that
|
||||
// returned rows to their start state: the attempt ceiling became unreachable,
|
||||
// so the row cycled forever, never reached a terminal status, and was
|
||||
// therefore never eligible for any retention sweep.
|
||||
// 2. A PARKED step is `running` with a NULL lease, and the reclaim only ever
|
||||
// touches a lease that is non-NULL and expired (the org lead's answer,
|
||||
// 2026-09-02). That is what lets a GM cue wait for a human overnight without a
|
||||
// sweep re-dispatching the instruction every fifteen minutes.
|
||||
|
||||
// A run's steps in authored order, for the phase the run is currently in.
|
||||
const listForPhase = async (runId, phase) =>
|
||||
(
|
||||
await query(
|
||||
'SELECT * FROM event_run_steps WHERE run_id = ? AND phase = ? ORDER BY seq, id',
|
||||
[runId, phase],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* The next step of a phase that the runner may work on, or null.
|
||||
*
|
||||
* **Steps within a phase are strictly serial.** This returns the lowest-`seq`
|
||||
* step that is not terminal, and the runner does nothing with step N+1 until N
|
||||
* has finished — which is the only reading under which `core.wait` means anything
|
||||
* at all, and the only one under which a cue can gate what follows it.
|
||||
*
|
||||
* A parked or running step is returned too, so the caller can see that the phase
|
||||
* is occupied rather than concluding it is finished.
|
||||
*/
|
||||
const nextOpenStep = async (runId, phase) => {
|
||||
const [row] = await query(
|
||||
`SELECT * FROM event_run_steps
|
||||
WHERE run_id = ? AND phase = ?
|
||||
AND status IN ('pending','running')
|
||||
ORDER BY seq, id LIMIT 1`,
|
||||
[runId, phase],
|
||||
)
|
||||
return hydrate(row) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of one pending step: the CAS `pending -> running`, plus a lease.
|
||||
*
|
||||
* `due_at` is honoured here rather than in the caller's filter so that the whole
|
||||
* decision — is it mine, is it due — is one statement the database arbitrates. A
|
||||
* NULL `due_at` is due now, which is what materialisation writes for every step
|
||||
* that is not sitting behind a `core.wait`.
|
||||
*/
|
||||
async function claim(id, owner, leaseUntil, now) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?,
|
||||
started_at = COALESCE(started_at, NOW())
|
||||
WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)`,
|
||||
[owner, leaseUntil, id, now],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a claimed step: it stays `running`, and its lease goes NULL.
|
||||
*
|
||||
* This is the whole mechanism behind `core.cue`. The step is genuinely in flight
|
||||
* — an instruction has been posted and nothing else in the phase may proceed —
|
||||
* but no process is holding it, so the reclaim must not take it back. A NULL
|
||||
* lease says exactly that, and `reclaimStale` below is written to agree.
|
||||
*/
|
||||
const park = (id, note) =>
|
||||
query(
|
||||
`UPDATE event_run_steps SET claim_expires_at = NULL, last_error = ?
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
[note ? String(note).slice(0, 500) : null, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Release a claimed step back to `pending` for a later attempt.
|
||||
*
|
||||
* `attempts` is untouched — it was already incremented by the claim, which is the
|
||||
* only place that may. Backoff is flat rather than exponential for the reason the
|
||||
* outbox's is: `due_at` is also the event's own clock, and a doubling backoff
|
||||
* pushes a step arbitrarily far past the moment the event was about.
|
||||
*/
|
||||
const reschedule = (id, dueAt, error) =>
|
||||
query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'pending', due_at = ?, claimed_by = NULL, claim_expires_at = NULL, last_error = ?
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
[dueAt, error ? String(error).slice(0, 500) : null, id],
|
||||
)
|
||||
|
||||
/** A terminal outcome for one step: done, failed, skipped, refused or cancelled. */
|
||||
const finish = (id, status, error) =>
|
||||
query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = ?, last_error = ?, finished_at = NOW(),
|
||||
claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
[status, error ? String(error).slice(0, 500) : null, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Delay the next not-yet-started step of a phase — what `core.wait` actually does.
|
||||
*
|
||||
* The wait step itself completes normally; the pause is the NEXT step's `due_at`,
|
||||
* owned by the runner. A `perform()` that slept would hold its claim for the
|
||||
* duration and turn a five-minute pause into a five-minute lease, which is the
|
||||
* one shape this must not have.
|
||||
*
|
||||
* Guarded on `status = 'pending'` and on the current `due_at` being sooner, so a
|
||||
* re-dispatch of a wait whose ack was lost cannot push the following step further
|
||||
* out a second time.
|
||||
*/
|
||||
const holdNext = async (runId, phase, afterSeq, dueAt) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET due_at = ?
|
||||
WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending'
|
||||
AND (due_at IS NULL OR due_at < ?)
|
||||
ORDER BY seq LIMIT 1`,
|
||||
[dueAt, runId, phase, afterSeq, dueAt],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover steps whose claim outlived the process that took it.
|
||||
*
|
||||
* **`attempts` is not reset and the lease being NULL is not staleness.** The
|
||||
* first is Engagement Phase 14's rule; the second is what makes a parked cue
|
||||
* survive. A step that has already burned its attempts leaves `running` as
|
||||
* `failed` rather than being handed back, and in that order — a reclaim that ran
|
||||
* first would return it to `pending` and it would be retried forever.
|
||||
*/
|
||||
const reclaimStale = async (now, maxAttempts = 0) => {
|
||||
let failed = 0
|
||||
if (Number(maxAttempts) > 0) {
|
||||
const gaveUp = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'failed', last_error = 'gave up after repeated interruptions',
|
||||
finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE status = 'running'
|
||||
AND claim_expires_at IS NOT NULL AND claim_expires_at < ?
|
||||
AND attempts >= ?`,
|
||||
[now, Math.floor(maxAttempts)],
|
||||
)
|
||||
failed = Number(gaveUp?.affectedRows || 0)
|
||||
}
|
||||
const reclaimed = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?`,
|
||||
[now],
|
||||
)
|
||||
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel every step of a run that has not started (Phase 3's cancel, and the
|
||||
* abort_run disposition).
|
||||
*
|
||||
* A `running` step is deliberately left alone, parked or not: nothing can recall
|
||||
* a command already sent, and a second writer on that row would race the process
|
||||
* that owns it (§L).
|
||||
*/
|
||||
const cancelPending = async (runId) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'cancelled', finished_at = NOW()
|
||||
WHERE run_id = ? AND status = 'pending'`,
|
||||
[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,
|
||||
getById,
|
||||
materialisePhase,
|
||||
statusCounts,
|
||||
idempotencyKey,
|
||||
nextOpenStep,
|
||||
claim,
|
||||
park,
|
||||
reschedule,
|
||||
finish,
|
||||
holdNext,
|
||||
reclaimStale,
|
||||
cancelPending,
|
||||
lastStartedSeq,
|
||||
confirmParked,
|
||||
skipByHuman,
|
||||
requeue,
|
||||
cancelOpen,
|
||||
}
|
||||
603
server/src/model/events/eventRuns.db.js
Normal file
603
server/src/model/events/eventRuns.db.js
Normal file
@@ -0,0 +1,603 @@
|
||||
// ── event_runs — SQL only ──────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §E. Phase 1 writes exactly one kind of row — a `scheduled`
|
||||
// occurrence — and reads them back for the admin surface. **The claim, the CAS
|
||||
// transitions and the lease reclaim are Phase 2's** and are deliberately not
|
||||
// stubbed here: a half-written claim is worse than no claim, because it reads as
|
||||
// protection.
|
||||
//
|
||||
// What Phase 1 does own is the INSERT, and it owns the important half of it:
|
||||
// materialisation is `INSERT IGNORE` against `UNIQUE (definition_id, scope,
|
||||
// scheduled_for)`, so a second attempt at one occurrence writes nothing and
|
||||
// answers honestly rather than raising a duplicate-key error a caller has to
|
||||
// interpret.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
|
||||
|
||||
// The statuses a run never leaves. A transition INTO one of these stamps
|
||||
// `ended_at` and drops the claim, and only rows in one of them are eligible for
|
||||
// the log retention sweep -- Engagement Phase 14's rule, which is a bound at all
|
||||
// only if every path a run can take reaches one of them.
|
||||
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
|
||||
|
||||
// §E's health values, worst last. Health is a HIGH-WATER MARK in this system —
|
||||
// nothing has ever cleared `degraded`, because a run whose announcement landed
|
||||
// on the second attempt did have trouble and that stays true for the rest of its
|
||||
// life — and `setHealth` enforces that rather than leaving it to every caller to
|
||||
// remember. `FIELD()` gives the same order inside the WHERE clause, 1-indexed,
|
||||
// which is what makes the guard one statement rather than a read and a write.
|
||||
const HEALTH_ORDER = ['ok', 'degraded', 'stalled']
|
||||
const HEALTH_RANK = Object.fromEntries(HEALTH_ORDER.map((h, i) => [h, i + 1]))
|
||||
const HEALTH_SQL_ORDER = HEALTH_ORDER.map((h) => `'${h}'`).join(', ')
|
||||
|
||||
// `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 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
|
||||
`
|
||||
|
||||
/**
|
||||
* The admin run list. Newest occurrence first, across every definition.
|
||||
*
|
||||
* `limit` is interpolated after an integer coercion rather than bound, because
|
||||
* MariaDB will not take a placeholder in LIMIT on a prepared statement. It never
|
||||
* reaches SQL as anything but a number.
|
||||
*/
|
||||
const list = async ({ definitionId = null, status = null, limit = 100 } = {}) => {
|
||||
const where = []
|
||||
const args = []
|
||||
if (definitionId) {
|
||||
where.push('r.definition_id = ?')
|
||||
args.push(definitionId)
|
||||
}
|
||||
if (status) {
|
||||
where.push('r.status = ?')
|
||||
args.push(status)
|
||||
}
|
||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await query(
|
||||
`${SELECT_LIST} ${clause} ORDER BY r.scheduled_for DESC, r.id DESC LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE r.id = ?`, [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise one occurrence. Answers the row id, or `null` when one already
|
||||
* existed — which is not an error and is the ordinary answer under a tick that
|
||||
* overran into the next one.
|
||||
*/
|
||||
const materialise = async (run) => {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_runs
|
||||
(definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
|
||||
params, rehearsal, started_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
run.definition_id,
|
||||
run.version_id,
|
||||
run.scope || '',
|
||||
run.scheduled_for,
|
||||
run.timezone || 'UTC',
|
||||
run.concurrency_key,
|
||||
run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
|
||||
run.rehearsal ? 1 : 0,
|
||||
run.started_by,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every run whose OCCUPIED INTERVAL overlaps a window — the calendar's real half.
|
||||
*
|
||||
* Ascending, unlike the admin run list: a calendar is read forwards. The join
|
||||
* reaches the series so a month can be filtered to one arc without a second
|
||||
* round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
|
||||
* a run records the zone it was COMPUTED in and a definition's zone can be
|
||||
* edited afterwards.
|
||||
*
|
||||
* **A run OVERLAPS the window; it does not merely START in it.** This asked
|
||||
* `scheduled_for >= from` alone until the Phase 16 acceptance walk, and a run is
|
||||
* not an instant — it is an interval, and a multi-phase event's whole point is
|
||||
* that the interval is long. A run that began before `from` and has not ended is
|
||||
* happening DURING the window and belongs in it. With the instant test, the
|
||||
* public calendar answered `entries: []` while that same event's own page said
|
||||
* `live: true`, so the site disagreed with itself about whether something was on
|
||||
* — and `EVENTS.md` §I promises this route serves "upcoming, **live** and
|
||||
* recent". The admin calendar had the same hole for the same reason: a run that
|
||||
* started last Sunday and is still going was missing from "this week".
|
||||
*
|
||||
* A finished run needs no clause: it is `recent` only if its instant is in the
|
||||
* window, which is what the window's own `from` decides (see
|
||||
* `eventPublic.model.calendar`, which backdates its default `from` so that
|
||||
* "recent" has somewhere to live).
|
||||
*/
|
||||
const LIVE_STATUSES = ['starting', 'running', 'paused', 'ending']
|
||||
|
||||
const listInWindow = async ({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
limit = 500,
|
||||
publicOnly = false,
|
||||
} = {}) => {
|
||||
const where = [
|
||||
`((r.scheduled_for >= ? AND r.scheduled_for < ?)
|
||||
OR (r.scheduled_for < ? AND r.status IN (${LIVE_STATUSES.map(() => '?').join(',')})))`,
|
||||
]
|
||||
const args = [from, to, to, ...LIVE_STATUSES]
|
||||
if (status) {
|
||||
where.push('r.status = ?')
|
||||
args.push(status)
|
||||
}
|
||||
// The public calendar's two exclusions, in SQL rather than in the model that
|
||||
// maps the rows. A rehearsal "is excluded from the public calendar and from
|
||||
// participation history" by §D's own column comment, and an unlisted
|
||||
// definition is one an operator chose not to announce. Both belong in the
|
||||
// query because a filter applied after the read is a filter somebody can
|
||||
// forget in the next caller.
|
||||
if (publicOnly) {
|
||||
where.push('r.rehearsal = 0', 'd.listed = 1', "d.state <> 'archived'")
|
||||
}
|
||||
if (scope !== null && scope !== undefined) {
|
||||
where.push('r.scope = ?')
|
||||
args.push(scope)
|
||||
}
|
||||
if (seriesId) {
|
||||
where.push('d.series_id = ?')
|
||||
args.push(seriesId)
|
||||
}
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 1000)
|
||||
const rows = await query(
|
||||
`SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
|
||||
d.series_id AS series_id, se.name AS series_name, se.slug AS series_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
|
||||
LEFT JOIN event_series se ON se.id = d.series_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY r.scheduled_for, r.id
|
||||
LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every not-yet-started occurrence of a definition at a new version.
|
||||
*
|
||||
* Publishing calls this, and the guard is the whole statement: `status =
|
||||
* 'scheduled'` and `started_at IS NULL`. A run that has begun keeps the version
|
||||
* it pinned, for ever, because that pin is what makes it explicable afterwards
|
||||
* -- and a run that has NOT begun has nothing to explain yet.
|
||||
*
|
||||
* **Why re-pinning is the right answer and doing nothing is not** (org lead,
|
||||
* 2026-09-02): occurrences are materialised a fortnight ahead, so on the day an
|
||||
* editor fixes a typo there are already fourteen days of rows carrying the old
|
||||
* spec. Left alone, the fix reaches none of them, and the operator's only
|
||||
* recourse -- cancelling each one -- is worse: a cancelled row still holds its
|
||||
* slot in `uq_evrun_occurrence`, so the occurrence does not come back on the new
|
||||
* version, it disappears.
|
||||
*
|
||||
* Answers how many were moved, so publish can say so rather than leaving it to
|
||||
* be noticed.
|
||||
*/
|
||||
const repinScheduled = async (definitionId, versionId) => {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET version_id = ?
|
||||
WHERE definition_id = ?
|
||||
AND status = 'scheduled'
|
||||
AND started_at IS NULL
|
||||
AND version_id <> ?`,
|
||||
[versionId, definitionId, versionId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/** The scheduled, not-yet-started occurrences a re-pin would move. */
|
||||
const listScheduledFor = async (definitionId) =>
|
||||
(
|
||||
await query(
|
||||
`SELECT id, version_id, scheduled_for FROM event_runs
|
||||
WHERE definition_id = ? AND status = 'scheduled' AND started_at IS NULL
|
||||
ORDER BY scheduled_for`,
|
||||
[definitionId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/** The occurrence the unique key names, whether or not this call created it. */
|
||||
const findOccurrence = async (definitionId, scope, scheduledFor) => {
|
||||
const [row] = await query(
|
||||
`${SELECT_LIST} WHERE r.definition_id = ? AND r.scope = ? AND r.scheduled_for = ?`,
|
||||
[definitionId, scope || '', scheduledFor],
|
||||
)
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Is anything of this definition not yet terminal? The archive pre-check. */
|
||||
const countActiveForDefinition = async (definitionId) => {
|
||||
const [row] = await query(
|
||||
`SELECT COUNT(*) AS n FROM event_runs
|
||||
WHERE definition_id = ?
|
||||
AND status IN ('scheduled','starting','running','paused','ending')`,
|
||||
[definitionId],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
// ── Phase 2: the claim, the transitions and the reclaim ────────────────────
|
||||
//
|
||||
// Everything below is the runner's, and none of it existed in Phase 1 for a
|
||||
// stated reason: a half-written claim is worse than no claim, because it reads
|
||||
// as protection. It is written here now, in full.
|
||||
//
|
||||
// **The division of labour with the unique index has not changed.** The index one
|
||||
// section up is what makes "one run per occurrence per scope" TRUE; the CAS below
|
||||
// decides only WHO advances an occurrence that already exists. Neither substitutes
|
||||
// for the other, and this deployment being single-instance (§N4) changes the test
|
||||
// rather than the design — the same two protections are what keep a tick that
|
||||
// overran into the next one from advancing a run twice.
|
||||
|
||||
/**
|
||||
* Runs the runner should look at this tick: due, and not yet terminal.
|
||||
*
|
||||
* It selects rather than claims — `claimStart` and `claimTick` below are one row
|
||||
* at a time — so two sweepers see the same candidates and then disagree,
|
||||
* harmlessly, about which of them owns each. `idx_evrun_due (status,
|
||||
* scheduled_for)` is this query.
|
||||
*
|
||||
* `paused` is absent from the status list on purpose. A paused run is waiting on
|
||||
* a human and must not be advanced by a tick; the only thing that moves it is
|
||||
* Phase 3's resume control.
|
||||
*/
|
||||
const findDue = async (now, limit = 50) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 500)
|
||||
return (
|
||||
await query(
|
||||
`SELECT * FROM event_runs
|
||||
WHERE status IN ('scheduled','starting','running','ending')
|
||||
AND scheduled_for <= ?
|
||||
ORDER BY scheduled_for, id
|
||||
LIMIT ${n}`,
|
||||
[now],
|
||||
)
|
||||
).map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of a run that has not started: the CAS `scheduled -> starting`.
|
||||
*
|
||||
* Verbatim the outbox claim the org lead settled over `SELECT ... FOR UPDATE
|
||||
* SKIP LOCKED` — the instance the server reports `affectedRows = 1` to owns the
|
||||
* row, every other sweeper gets 0 and moves on. No transaction to hold open and
|
||||
* no MariaDB version floor.
|
||||
*/
|
||||
async function claimStart(id, owner, leaseUntil) {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET status = 'starting', claimed_by = ?, claim_expires_at = ?,
|
||||
started_at = COALESCE(started_at, NOW())
|
||||
WHERE id = ? AND status = 'scheduled'`,
|
||||
[owner, leaseUntil, id],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a lease on a run already in flight, so one tick works on it at a time.
|
||||
*
|
||||
* Unlike `claimStart` this does not change `status` — the run is already
|
||||
* `starting`, `running` or `ending`, and what is being claimed is the right to
|
||||
* advance it.
|
||||
*
|
||||
* **A live lease is not re-enterable, not even by the process that took it**, and
|
||||
* that is the whole point rather than an oversight. `setInterval` fires the next
|
||||
* tick whether or not the last one has returned, so an owner-matches escape
|
||||
* clause here would let one process advance one run twice at once — which is
|
||||
* precisely the overrun the plan says the CAS is meant to protect against. A run
|
||||
* this process still holds is a run this process is still working on; the tick
|
||||
* skips it, and `releaseClaim` below is what ends that in the ordinary case.
|
||||
*/
|
||||
async function claimTick(id, owner, leaseUntil, now) {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET claimed_by = ?, claim_expires_at = ?
|
||||
WHERE id = ?
|
||||
AND status IN ('starting','running','ending')
|
||||
AND (claim_expires_at IS NULL OR claim_expires_at < ?)`,
|
||||
[owner, leaseUntil, id, now],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a still-in-flight run back, so the next tick can pick it up at once.
|
||||
*
|
||||
* A run left parked on a GM cue, or waiting out a `core.wait`, is not finished
|
||||
* and must not carry a lease: without this the run would be unadvanceable until
|
||||
* the lease expired, which would turn every wait into `max(wait, leaseMs)`.
|
||||
* Scoped to `claimed_by = ?` so a process can only release its own claim.
|
||||
*/
|
||||
async function releaseClaim(id, owner) {
|
||||
const result = await query(
|
||||
'UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL WHERE id = ? AND claimed_by = ?',
|
||||
[id, owner],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* A guarded status transition: `from -> to`, and only from `from`.
|
||||
*
|
||||
* Every move the runner makes goes through here rather than through a bare
|
||||
* UPDATE, so "did this transition actually happen" is answerable at each call
|
||||
* site. A `false` is not an error — it is another worker, or this run having been
|
||||
* cancelled from the admin surface between the read and the write, which is a
|
||||
* race Phase 3's live controls make ordinary.
|
||||
*/
|
||||
async function transition(id, from, to, { phase, error, clearClaim = false } = {}) {
|
||||
const sets = ['status = ?']
|
||||
const args = [to]
|
||||
if (phase !== undefined) {
|
||||
sets.push('current_phase = ?')
|
||||
args.push(phase)
|
||||
}
|
||||
if (error !== undefined) {
|
||||
sets.push('last_error = ?')
|
||||
args.push(error === null ? null : String(error).slice(0, 500))
|
||||
}
|
||||
if (TERMINAL.includes(to)) sets.push('ended_at = COALESCE(ended_at, NOW())')
|
||||
if (clearClaim || TERMINAL.includes(to)) sets.push('claimed_by = NULL', 'claim_expires_at = NULL')
|
||||
|
||||
const froms = Array.isArray(from) ? from : [from]
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET ${sets.join(', ')}
|
||||
WHERE id = ? AND status IN (${froms.map(() => '?').join(',')})`,
|
||||
[...args, id, ...froms],
|
||||
)
|
||||
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).
|
||||
*
|
||||
* The two columns are separate because a run can be genuinely running and
|
||||
* degraded at once — announcements landing, world writes parked — and one column
|
||||
* cannot say both. Guarded on the current value so a tick that re-observes the
|
||||
* same degradation does not restamp `updated_at`.
|
||||
*/
|
||||
async function setHealth(id, health) {
|
||||
// **Escalation only, and this is the guard rather than a convention.** Health
|
||||
// has always been a high-water mark here — `degraded` is never cleared,
|
||||
// because a run whose announcement landed on the second attempt DID have
|
||||
// trouble and that stays true — and Phase 5 gave the column a second writer
|
||||
// for `stalled`. Without a rank, a step that retried after a stall would
|
||||
// quietly demote `stalled` to `degraded` and a run that waited ninety minutes
|
||||
// on a boss that never came would end its life claiming it merely wobbled.
|
||||
const rank = HEALTH_RANK[health]
|
||||
if (!rank) return false
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET health = ?
|
||||
WHERE id = ? AND FIELD(health, ${HEALTH_SQL_ORDER}) < ?`,
|
||||
[health, id, rank],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Set `cleanup_status`, optionally guarded on where it is now (Phase 8).
|
||||
*
|
||||
* Four values and three writers, which is why the guard is a parameter rather
|
||||
* than baked in. The ledger stamps `pending` the first time a run records
|
||||
* anything, and it must do so only over `not_required` — a run already marked
|
||||
* `complete` must not be walked back to `pending` by a late resource, and a
|
||||
* `incomplete` one must not be silently tidied. The cleanup sweep sets `complete`
|
||||
* or `incomplete` from what it found, unguarded, because the sweep IS the
|
||||
* authority on that. A human's cleanup route re-opens `pending` deliberately, and
|
||||
* says so in the log with the actor.
|
||||
*
|
||||
* **`pending` on a run that is still running is not a bug and reads correctly**:
|
||||
* there is something to clean up and it has not happened yet. The alternative -
|
||||
* a fifth value meaning "there will be something later" - is a state nothing
|
||||
* would ever branch on.
|
||||
*/
|
||||
async function setCleanupStatus(id, to, from = null) {
|
||||
const guard = from === null ? '' : ` AND cleanup_status IN (${from.map(() => '?').join(',')})`
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET cleanup_status = ? WHERE id = ?${guard}`,
|
||||
from === null ? [to, id] : [to, id, ...from],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp this run's results table as published (EVENTS.md §J, Phase 10).
|
||||
*
|
||||
* **Unguarded, and re-stampable.** `core.results.publish` is an ordinary step
|
||||
* that an author may place more than once — before an announcement and again
|
||||
* after a late correction — and each publication is a real one whose moment is
|
||||
* worth recording. Guarding it on `IS NULL` would make the second silently do
|
||||
* nothing while the ranking beside it did move, which is the worst of both.
|
||||
*/
|
||||
async function markResultsPublished(id, at = new Date()) {
|
||||
const result = await query('UPDATE event_runs SET results_published_at = ? WHERE id = ?', [at, id])
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs whose start instant passed more than their own grace window ago (§E, §L).
|
||||
*
|
||||
* The window is per definition, so the comparison is against `grace_seconds` on
|
||||
* the joined row rather than against a constant here: an event whose announcement
|
||||
* gives a fifteen-minute window and one that must start on the second are the
|
||||
* same query with different data.
|
||||
*
|
||||
* Only `scheduled` runs qualify. A run that reached `starting` has begun, and
|
||||
* "began and then stalled" is a different fact from "never began" — conflating
|
||||
* them would let `missed` describe a run that had already announced itself.
|
||||
*/
|
||||
const findMissed = async (now, limit = 100) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
return (
|
||||
await query(
|
||||
`SELECT r.* FROM event_runs r
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
WHERE r.status = 'scheduled'
|
||||
AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?
|
||||
ORDER BY r.scheduled_for
|
||||
LIMIT ${n}`,
|
||||
[now],
|
||||
)
|
||||
).map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Is another run holding this concurrency key?
|
||||
*
|
||||
* The org lead's answer for a held key (2026-09-02) is to leave the run
|
||||
* `scheduled` and let the grace window decide, so this is a READ rather than a
|
||||
* claim: the caller holds off, logs which run holds the key, and tries again next
|
||||
* tick. `idx_evrun_concurrency (concurrency_key, status)` is this query, and a
|
||||
* NULL key is skipped by that index — which is right, because a definition with
|
||||
* no key never contends.
|
||||
*/
|
||||
const concurrencyHolder = async (key, exceptRunId) => {
|
||||
if (!key) return null
|
||||
const [row] = await query(
|
||||
`SELECT id, status, definition_id FROM event_runs
|
||||
WHERE concurrency_key = ?
|
||||
AND id <> ?
|
||||
AND status IN ('starting','running','paused','ending')
|
||||
ORDER BY id LIMIT 1`,
|
||||
[key, exceptRunId],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover runs whose claim outlived the process that took it.
|
||||
*
|
||||
* **It does not change status and it touches no counter.** All it releases is the
|
||||
* lease; the run stays exactly where it was and the next tick picks it up through
|
||||
* `findDue`. This is Engagement Phase 14's lesson applied one table over: a sweep
|
||||
* that returned a stale row to its start state made the attempt ceiling
|
||||
* unreachable, so the row cycled forever, never terminal, and therefore never
|
||||
* eligible for any retention sweep.
|
||||
*/
|
||||
const reclaimStale = async (now) => {
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE status IN ('starting','running','ending')
|
||||
AND claim_expires_at IS NOT NULL
|
||||
AND claim_expires_at < ?`,
|
||||
[now],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition's public occurrences, newest first (Phase 14a).
|
||||
*
|
||||
* Rehearsals are excluded here rather than by the caller, for `listInWindow`'s
|
||||
* reason. The definition's own `listed`/`state` are NOT re-checked: the only
|
||||
* caller has already resolved the definition through `getPublicBySlug`, and a
|
||||
* second copy of that rule is a second thing to keep in step with the first.
|
||||
*
|
||||
* `scheduled` runs come back too — an upcoming occurrence is exactly what a
|
||||
* visitor came to the page for — and the caller splits past from future on the
|
||||
* instant rather than on the status, because a `missed` run is in the past
|
||||
* whatever its status says.
|
||||
*/
|
||||
const listPublicForDefinition = async (definitionId, limit = 50) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const rows = await query(
|
||||
`SELECT r.*, v.version AS version_number
|
||||
FROM event_runs r
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
WHERE r.definition_id = ? AND r.rehearsal = 0
|
||||
ORDER BY r.scheduled_for DESC, r.id DESC
|
||||
LIMIT ${n}`,
|
||||
[definitionId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
|
||||
const terminalBefore = async (before, limit = 500) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
|
||||
return (
|
||||
await query(
|
||||
`SELECT id FROM event_runs
|
||||
WHERE status IN (${TERMINAL.map(() => '?').join(',')})
|
||||
AND COALESCE(ended_at, updated_at) < ?
|
||||
ORDER BY id LIMIT ${n}`,
|
||||
[...TERMINAL, before],
|
||||
)
|
||||
).map((r) => Number(r.id))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
materialise,
|
||||
listInWindow,
|
||||
listPublicForDefinition,
|
||||
repinScheduled,
|
||||
listScheduledFor,
|
||||
findOccurrence,
|
||||
countActiveForDefinition,
|
||||
findDue,
|
||||
findMissed,
|
||||
claimStart,
|
||||
claimTick,
|
||||
releaseClaim,
|
||||
statusOf,
|
||||
transition,
|
||||
setHealth,
|
||||
setCleanupStatus,
|
||||
markResultsPublished,
|
||||
concurrencyHolder,
|
||||
reclaimStale,
|
||||
terminalBefore,
|
||||
TERMINAL,
|
||||
}
|
||||
288
server/src/model/events/eventRuns.model.js
Normal file
288
server/src/model/events/eventRuns.model.js
Normal file
@@ -0,0 +1,288 @@
|
||||
// ── Event runs — creating an occurrence ────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's
|
||||
// steps. **It does not start anything**: there is no runner until Phase 2, so a
|
||||
// row created here sits at `scheduled` indefinitely. That is the correct
|
||||
// behaviour for this phase and it has to be VISIBLE as such rather than looking
|
||||
// broken, which is why `create()` answers with the row and the admin surface
|
||||
// renders the status verbatim.
|
||||
//
|
||||
// Two properties this file owns, both of which are the reason it exists before
|
||||
// the runner rather than with it:
|
||||
//
|
||||
// - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two
|
||||
// attempts at one occurrence produce one row and an honest answer, not a
|
||||
// duplicate-key error a caller has to interpret. The unique index — not a
|
||||
// claim — is what makes "one run per occurrence per scope" true (§E).
|
||||
// - **The idempotency key is minted with the step row and never varies by
|
||||
// attempt.** It is a function of identity, so it can only be stable if it is
|
||||
// stamped where the identity is created.
|
||||
|
||||
const db = require('./eventRuns.db')
|
||||
const stepsDb = require('./eventRunSteps.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const gatesDb = require('./eventPhaseGates.db')
|
||||
const gates = require('../../events/gates')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const settingsDb = require('./eventActionSettings.db')
|
||||
const budgetDb = require('./eventRunBudget.db')
|
||||
const resourcesDb = require('./eventRunResources.db')
|
||||
const participantsDb = require('./eventRunParticipants.db')
|
||||
const authorize = require('../../events/authorize')
|
||||
|
||||
const MAX_SCOPE = 190
|
||||
|
||||
/**
|
||||
* Render a definition's `concurrency_key` template against a run's params.
|
||||
*
|
||||
* `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A
|
||||
* placeholder with no matching param is left standing rather than replaced with
|
||||
* an empty string: `invasion:` would collide with every other unrendered key on
|
||||
* the deployment, which is the opposite of what a concurrency key is for, and a
|
||||
* literal `invasion:{region}` in the column is a visible mistake.
|
||||
*/
|
||||
function renderConcurrencyKey(template, params) {
|
||||
if (!template) return null
|
||||
return String(template)
|
||||
.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => {
|
||||
const value = params && params[name]
|
||||
return value === undefined || value === null || value === '' ? whole : String(value)
|
||||
})
|
||||
.slice(0, MAX_SCOPE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one occurrence of a definition and materialise its first phase.
|
||||
*
|
||||
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
|
||||
* the present, not a separate concept, which is what keeps the runner's one
|
||||
* materialise/advance path honest now that Phase 4 has put recurrence on top.
|
||||
*
|
||||
* **Phase 4's expansion calls this, rather than a second insert path beside it.**
|
||||
* That is deliberate: every check here — the definition is still `ready`, the
|
||||
* version still has phases, the concurrency key renders, the first phase's steps
|
||||
* are materialised with their idempotency keys — is one a scheduled occurrence
|
||||
* needs at least as much as a hand-started one, because there is nobody watching
|
||||
* when it happens. The `INSERT IGNORE` answering `created: false` is what makes
|
||||
* it safe to call on every tick for every occurrence inside the horizon.
|
||||
*/
|
||||
async function create(
|
||||
definitionId,
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
|
||||
userId,
|
||||
) {
|
||||
const definition = await definitionsDb.getById(definitionId)
|
||||
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (definition.state !== 'ready') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
errors: [`a ${definition.state} definition has no published version to run`],
|
||||
}
|
||||
}
|
||||
if (!definition.current_version_id) {
|
||||
return { ok: false, status: 409, errors: ['this definition has no published version'] }
|
||||
}
|
||||
|
||||
const version = await versionsDb.getById(definition.current_version_id)
|
||||
if (!version?.spec?.phases?.length) {
|
||||
return { ok: false, status: 409, errors: ['the published version has no phases'] }
|
||||
}
|
||||
|
||||
// §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03):
|
||||
// *"a scheduled definition that has never been verified is the case worth
|
||||
// refusing to start"*. An admin pressing start is watching, and that human IS
|
||||
// the review the gate exists to require — so the gate falls on the path where
|
||||
// nobody is. A version is immutable, so a dry run that passed against it stays
|
||||
// true, which is what makes the pass a property of the version rather than
|
||||
// something re-earned every occurrence.
|
||||
if (source === 'schedule' && !version.verified_at) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: 'unverified',
|
||||
errors: ['this version has not been verified, so it will not start unattended'],
|
||||
}
|
||||
}
|
||||
|
||||
const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
|
||||
const when = scheduledFor ? new Date(scheduledFor) : new Date()
|
||||
if (Number.isNaN(when.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['scheduledFor is not a date'] }
|
||||
}
|
||||
|
||||
const runId = await db.materialise({
|
||||
definition_id: definitionId,
|
||||
version_id: version.id,
|
||||
scope: scopeValue,
|
||||
// Stored as UTC. The definition's zone is what an occurrence is COMPUTED in
|
||||
// (Phase 4); what is stored is the instant.
|
||||
scheduled_for: when,
|
||||
timezone: definition.timezone,
|
||||
concurrency_key: renderConcurrencyKey(definition.concurrency_key, params),
|
||||
params,
|
||||
rehearsal,
|
||||
started_by: userId,
|
||||
})
|
||||
|
||||
if (runId === null) {
|
||||
// The occurrence already existed. Not an error — it is what the unique index
|
||||
// is for — so the existing row is the answer.
|
||||
const existing = await db.findOccurrence(definitionId, scopeValue, when)
|
||||
return { ok: true, created: false, run: existing }
|
||||
}
|
||||
|
||||
await logDb.write({
|
||||
runId,
|
||||
kind: 'run.created',
|
||||
detail: {
|
||||
definitionId,
|
||||
versionId: version.id,
|
||||
version: version.version,
|
||||
scope: scopeValue,
|
||||
rehearsal: Boolean(rehearsal),
|
||||
// 'manual' is an admin pressing start; 'schedule' is the runner expanding
|
||||
// a recurrence (Phase 4). Both produce the same row, and the log is the
|
||||
// only place the difference is recorded — `started_by` is NULL for both a
|
||||
// scheduled occurrence and one started by a since-deleted account.
|
||||
source,
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// The run's budget, seeded from EVERY phase's steps rather than from the first
|
||||
// one's (Phase 6). The version is pinned and immutable, so all of its steps are
|
||||
// knowable now — and a budget that grew as phases were entered would let a
|
||||
// phase-1 step spend a cap that a phase-3 step was going to need, which is the
|
||||
// opposite of a per-run bound. The caps are copied here, so an admin moving a
|
||||
// switch tomorrow does not change what a run already in flight is allowed.
|
||||
const allSteps = version.spec.phases.flatMap((p) =>
|
||||
(p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })),
|
||||
)
|
||||
const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId))
|
||||
const budget = authorize.effectiveCaps(allSteps, settingsByAction)
|
||||
if (Object.keys(budget).length) {
|
||||
await budgetDb.seed(runId, budget)
|
||||
await logDb.write({
|
||||
runId,
|
||||
kind: 'run.budget',
|
||||
detail: {
|
||||
dimensions: Object.entries(budget).map(([dimension, d]) => ({
|
||||
dimension,
|
||||
cap: d.cap,
|
||||
from: d.from,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The first phase's steps, materialised at creation rather than at start.
|
||||
// Phase 2 materialises each LATER phase as the run enters it; doing the first
|
||||
// one here is what makes a Phase 1 run row inspectable — an operator can see
|
||||
// the steps that would run, with their params and their idempotency keys,
|
||||
// before there is anything to run them.
|
||||
const first = version.spec.phases[0]
|
||||
await stepsDb.materialisePhase(runId, first.key, first.steps || [])
|
||||
await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } })
|
||||
|
||||
return { ok: true, created: true, run: await db.getById(runId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A run, its steps, its status counts and its phase gates — the run console.
|
||||
*
|
||||
* The gates arrive already DESCRIBED rather than as rows (Phase 5): the panel's
|
||||
* whole value is that it reads the way the condition builder reads, and those
|
||||
* words come from `engagement/conditions.js`'s own operator labels. Rendering
|
||||
* them in the browser would be a second implementation of a grammar the server
|
||||
* owns, and the first clause the two spelled differently would meet its operator
|
||||
* at two in the morning.
|
||||
*
|
||||
* Every gate the run has opened is returned, not only the current phase's. A
|
||||
* completed phase's gate answers "how long did phase 2 actually wait, and what
|
||||
* released it" — which is the same question as the live one, asked afterwards.
|
||||
*/
|
||||
async function detail(runId) {
|
||||
const run = await db.getById(runId)
|
||||
if (!run) return null
|
||||
const [steps, counts, gateRows, budget, resources, attendees] = await Promise.all([
|
||||
stepsDb.listForRun(runId),
|
||||
stepsDb.statusCounts(runId),
|
||||
gatesDb.listForRun(runId),
|
||||
budgetDb.forRun(runId),
|
||||
resourcesDb.forRun(runId),
|
||||
participantsDb.listForRun(runId),
|
||||
])
|
||||
const now = new Date()
|
||||
return {
|
||||
run,
|
||||
steps,
|
||||
counts,
|
||||
gates: gateRows.map((g) => gates.describe(g, now)),
|
||||
// The meter, as rows rather than as a sentence: a cap is two numbers and a
|
||||
// name, and unlike a gate it needs no grammar rendered to be read. `cap:
|
||||
// null` is uncapped and the client says so — a dimension the run counts but
|
||||
// nothing bounds.
|
||||
budget: budget.map((b) => ({
|
||||
dimension: b.dimension,
|
||||
consumed: b.consumed,
|
||||
cap: b.cap,
|
||||
from: b.effective_from,
|
||||
})),
|
||||
// What this run changed in the world, and what became of it (Phase 8). The
|
||||
// WHOLE ledger, reverted rows included, because "what did last night's
|
||||
// invasion actually spawn, and did all of it come back" is the question this
|
||||
// panel exists for and a list of only the failures cannot answer the second
|
||||
// half of it.
|
||||
//
|
||||
// **The `@step` placeholders are filtered out.** They are core's own
|
||||
// bookkeeping — a row that says "a dispatch is in flight and may have made
|
||||
// something" — and the console's list is of things in the world. One left in
|
||||
// would read as a resource nobody can name, which is exactly the confusion it
|
||||
// exists to prevent internally.
|
||||
resources: resources
|
||||
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
stepId: r.step_id,
|
||||
module: r.owner_module,
|
||||
kind: r.kind,
|
||||
ref: r.ref,
|
||||
payload: r.payload,
|
||||
leaseUntil: r.lease_until,
|
||||
status: r.status,
|
||||
revertAttempts: r.revert_attempts,
|
||||
lastError: r.last_error,
|
||||
memberKey: r.member_key,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
// How many rows are still unresolved, counted over the WHOLE ledger rather
|
||||
// than over the list above — a placeholder left standing by a lost
|
||||
// acknowledgement is exactly the case `cleanup_status` must not call clean.
|
||||
unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length,
|
||||
// Who took part, best first (Phase 10). Returned on every run rather than
|
||||
// only on a published one: the console's question is "what did this event
|
||||
// record", and a run whose module has collected but whose author never
|
||||
// placed a publish step is exactly the case an operator needs to see. What
|
||||
// `results_published_at` on the run row then says is whether anyone OUTSIDE
|
||||
// this screen may read it — which is Phase 14's question, not this one's.
|
||||
//
|
||||
// **`rank` is `rank_at`, renamed at the boundary and not in the column.**
|
||||
// `rank` is a reserved word in MariaDB 10.2+ (it is the window function),
|
||||
// so the column carries the suffix and the API carries the name a client
|
||||
// wants. The alternative — backticking the column at every use — is one
|
||||
// forgotten pair of backticks away from a syntax error in a query nobody
|
||||
// runs until a run completes at four in the morning.
|
||||
participants: attendees.map((p) => ({
|
||||
memberKey: p.member_key,
|
||||
userId: p.user_id,
|
||||
score: p.score,
|
||||
rank: p.rank_at,
|
||||
joinedAt: p.joined_at,
|
||||
meta: p.meta,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { create, detail, renderConcurrencyKey }
|
||||
78
server/src/model/events/eventSeries.db.js
Normal file
78
server/src/model/events/eventSeries.db.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// ── event_series — SQL only ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
|
||||
// reads — `event_definitions.series_id` is a foreign key and the definition save
|
||||
// path has to check it resolves — and Phase 4 adds the writes, because the
|
||||
// calendar is what makes an arc visible and a form cannot offer a value nobody
|
||||
// can create.
|
||||
//
|
||||
// `ordering` here places a SERIES among the others on the calendar. A
|
||||
// definition's place WITHIN its arc is `event_definitions.series_order`, which
|
||||
// is the column an editor drags; the two are deliberately different columns on
|
||||
// different tables and the schema comment says so.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
|
||||
// the list is a handful of rows, and the delete path needs the same number to
|
||||
// tell an operator what they are about to detach.
|
||||
const SELECT_LIST = `
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
|
||||
FROM event_series s
|
||||
`
|
||||
|
||||
const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
|
||||
return row || null
|
||||
}
|
||||
|
||||
const getBySlug = async (slug) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE s.slug = ?`, [slug])
|
||||
return row || null
|
||||
}
|
||||
|
||||
const exists = async (id) => {
|
||||
const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
/** Does any OTHER series hold this slug? The uniqueness pre-check. */
|
||||
const slugTaken = async (slug, exceptId = null) => {
|
||||
const rows = exceptId
|
||||
? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
|
||||
: await query('SELECT id FROM event_series WHERE slug = ?', [slug])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const insert = async (s) => {
|
||||
const result = await query(
|
||||
`INSERT INTO event_series (name, slug, description, ordering, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[s.name, s.slug, s.description, s.ordering, s.created_by],
|
||||
)
|
||||
return Number(result.insertId)
|
||||
}
|
||||
|
||||
const update = (id, s) =>
|
||||
query(
|
||||
`UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
|
||||
[s.name, s.slug, s.description, s.ordering, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* A hard delete, and the one place in this feature that is one.
|
||||
*
|
||||
* A series is a label rather than authored content: nothing pins one, no run
|
||||
* references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
|
||||
* removing a series detaches its definitions and destroys nothing. That is why
|
||||
* it is not archived the way a definition is — an archived label would be a
|
||||
* state every calendar query has to remember for no benefit. The model answers
|
||||
* with how many definitions were detached, so the operator learns what happened
|
||||
* rather than discovering it on the calendar.
|
||||
*/
|
||||
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }
|
||||
93
server/src/model/events/eventSeries.model.js
Normal file
93
server/src/model/events/eventSeries.model.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// ── Event series — the arc ─────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §I. "Royal Spy Mission → Risky Partner → Message From the
|
||||
// Void" is continuity that exists nowhere in the tooling this feature replaces
|
||||
// (§ "What the real calendar shows, and what it is missing": *no series or
|
||||
// recurrence field*). One small table buys it, and this is the policy half.
|
||||
//
|
||||
// **Why the writes are `admin, editor` and not `admin`.** A series is authoring,
|
||||
// and it is the same act as writing the definition that goes in it — §N2's
|
||||
// narrow gate is about *committing the deployment to a run* (publish, start),
|
||||
// which naming an arc does not do. An editor who can write the events but not
|
||||
// the arc they belong to would have to ask an admin to type a title.
|
||||
//
|
||||
// **A slug is derived once and then frozen**, exactly as a definition's is: the
|
||||
// public arc page lives at `/events/series/:slug` (Phase 14), and a slug that
|
||||
// moved would break every link to it. Renaming the series is free.
|
||||
|
||||
const db = require('./eventSeries.db')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
|
||||
const MAX_NAME = 160
|
||||
const MAX_DESCRIPTION = 2000
|
||||
|
||||
const trimOrNull = (v, max) => {
|
||||
if (v === undefined || v === null) return null
|
||||
const s = String(v).trim()
|
||||
return s === '' ? null : s.slice(0, max)
|
||||
}
|
||||
|
||||
const list = () => db.list()
|
||||
|
||||
const getById = (id) => db.getById(id)
|
||||
|
||||
async function validate(input, { existing = null } = {}) {
|
||||
const errors = []
|
||||
const body = input && typeof input === 'object' ? input : {}
|
||||
|
||||
const name = trimOrNull(body.name, MAX_NAME)
|
||||
if (!name) errors.push('name is required')
|
||||
|
||||
const description = trimOrNull(body.description, MAX_DESCRIPTION)
|
||||
|
||||
const orderingRaw = body.ordering === undefined ? (existing?.ordering ?? 0) : body.ordering
|
||||
const ordering = Number(orderingRaw)
|
||||
if (!Number.isInteger(ordering) || ordering < 0 || ordering > 9999) {
|
||||
errors.push('ordering must be an integer 0..9999')
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, series: { name, description, ordering } }
|
||||
}
|
||||
|
||||
async function create(input, userId) {
|
||||
const checked = await validate(input)
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// The taken set is read here rather than inside `uniqueSlug` because that
|
||||
// helper is pure — the same shape the team and definition paths use.
|
||||
const taken = (await db.list()).map((s) => s.slug)
|
||||
const slug = uniqueSlug(checked.series.name, taken, { fallback: 'series' })
|
||||
|
||||
const id = await db.insert({ ...checked.series, slug, created_by: userId || null })
|
||||
return { ok: true, status: 201, series: await db.getById(id) }
|
||||
}
|
||||
|
||||
async function update(id, input, userId) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
|
||||
|
||||
const checked = await validate(input, { existing })
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// The slug is the existing one, deliberately: renaming a series must not move
|
||||
// the address its arc page lives at.
|
||||
await db.update(id, { ...checked.series, slug: existing.slug })
|
||||
return { ok: true, status: 200, series: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a series, detaching whatever belonged to it.
|
||||
*
|
||||
* The count comes back so the caller can say *"3 events were detached"* rather
|
||||
* than leaving an operator to notice on the calendar. `series_id` is
|
||||
* `ON DELETE SET NULL`, so nothing is destroyed and re-attaching is a dropdown.
|
||||
*/
|
||||
async function remove(id) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
|
||||
await db.remove(id)
|
||||
return { ok: true, status: 200, detached: Number(existing.definition_count || 0) }
|
||||
}
|
||||
|
||||
module.exports = { list, getById, validate, create, update, remove, slugify, MAX_NAME }
|
||||
74
server/src/model/events/eventVersions.db.js
Normal file
74
server/src/model/events/eventVersions.db.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// ── event_versions — SQL only ──────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D. Immutable: there is an insert and there are reads, and there is
|
||||
// deliberately no update and no delete. A run pins a version, and that pin is
|
||||
// what makes the run reproducible and an audit answerable after the definition
|
||||
// has been edited underneath it.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, spec: parseJson(row.spec, null) }
|
||||
|
||||
const listForDefinition = async (definitionId) =>
|
||||
(
|
||||
await query(
|
||||
`SELECT v.id, v.definition_id, v.version, v.published_at, v.published_by, u.username AS published_by_username
|
||||
FROM event_versions v
|
||||
LEFT JOIN users u ON u.id = v.published_by
|
||||
WHERE v.definition_id = ?
|
||||
ORDER BY v.version DESC`,
|
||||
[definitionId],
|
||||
)
|
||||
).map((row) => row)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM event_versions WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The next version number for a definition.
|
||||
*
|
||||
* Read separately and then INSERTed, which is a read-then-write — and it is safe
|
||||
* only because `UNIQUE (definition_id, version)` is behind it. Two publishes
|
||||
* racing for version 4 is one 1062 the caller reports, not two rows called 4.
|
||||
* The unique index is the mechanism; this query is the ergonomics.
|
||||
*/
|
||||
const nextVersion = async (definitionId) => {
|
||||
const [row] = await query(
|
||||
'SELECT COALESCE(MAX(version), 0) + 1 AS next FROM event_versions WHERE definition_id = ?',
|
||||
[definitionId],
|
||||
)
|
||||
return Number(row?.next || 1)
|
||||
}
|
||||
|
||||
const insert = async (definitionId, version, spec, userId) => {
|
||||
const result = await query(
|
||||
'INSERT INTO event_versions (definition_id, version, spec, published_by) VALUES (?, ?, ?, ?)',
|
||||
[definitionId, version, JSON.stringify(spec), userId],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a dry run passed against this version (Phase 6).
|
||||
*
|
||||
* A version is immutable in every respect that describes the EVENT — its spec,
|
||||
* its number, who published it. These two columns describe something that
|
||||
* happened to it afterwards, which is why they can be written at all: a pass is
|
||||
* a fact about a review, not a change to the plan reviewed.
|
||||
*
|
||||
* Deliberately not idempotent-checked: verifying twice stamps the second one, and
|
||||
* the later reviewer is the more useful answer to "who last looked at this
|
||||
* before it ran unattended".
|
||||
*/
|
||||
const markVerified = async (id, userId, at = new Date()) => {
|
||||
const result = await query(
|
||||
'UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?',
|
||||
[at, userId, id],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
module.exports = { listForDefinition, getById, nextVersion, insert, markVerified }
|
||||
@@ -12,6 +12,19 @@ async function listPublished(category) {
|
||||
)
|
||||
}
|
||||
|
||||
// Every published post, across categories, newest first — the option source
|
||||
// behind `core.announce.post`'s `postId` param (EVENTS.md §F, Phase 10). Its own
|
||||
// query rather than a loop over `listPublished` because an authoring dropdown
|
||||
// wants one bounded, ordered list and needs neither the body nor the excerpt: a
|
||||
// hundred posts' bodies would be a megabyte of HTML sent to draw a `<select>`.
|
||||
async function listPublishedForOptions(limit = 200) {
|
||||
const n = Math.min(Math.max(Number(limit) || 200, 1), 500)
|
||||
return query(
|
||||
'SELECT id, category, title FROM posts WHERE published = 1 ' +
|
||||
`ORDER BY COALESCE(published_at, created_at) DESC, id DESC LIMIT ${n}`,
|
||||
)
|
||||
}
|
||||
|
||||
// All posts for a category (admin), newest first.
|
||||
async function listAll(category) {
|
||||
if (category) {
|
||||
@@ -74,6 +87,7 @@ async function countByCategory() {
|
||||
|
||||
module.exports = {
|
||||
listPublished,
|
||||
listPublishedForOptions,
|
||||
listAll,
|
||||
findById,
|
||||
findPublished,
|
||||
|
||||
@@ -36,6 +36,24 @@ async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a one-shot guard, atomically. `true` means THIS caller wrote the row.
|
||||
*
|
||||
* The same `INSERT IGNORE` as `seedDefault`, and the difference is the whole
|
||||
* point: this one reports whether it won. A guard read with `get()` and written
|
||||
* later with `set()` is not a guard at all under concurrency — two processes
|
||||
* both read "absent" and both proceed — and this is used where proceeding twice
|
||||
* means seeding a rule group twice, i.e. two mails per event.
|
||||
*
|
||||
* The atomicity is the PRIMARY KEY's: exactly one INSERT can create a given
|
||||
* `key`, so exactly one caller sees `affectedRows === 1`. No transaction and no
|
||||
* lock, the same bargain `engagementWorker`'s claim makes.
|
||||
*/
|
||||
async function claim(key, value) {
|
||||
const res = await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
return Number(res && res.affectedRows) === 1
|
||||
}
|
||||
|
||||
// Delete a settings row. "Reset to defaults" for the theming/nav keys is the
|
||||
// *absence* of a row, not a stored copy of the defaults — see
|
||||
// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a
|
||||
@@ -44,4 +62,4 @@ async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, remove }
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, claim, remove }
|
||||
|
||||
@@ -174,6 +174,32 @@ async function boot({ modules, model } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// What a module SHIPS as engagement content — its message bodies and its
|
||||
// seeded rules (ENGAGEMENT.md Phase 11b, decision 7).
|
||||
//
|
||||
// **Here rather than in `seedDefaults()`, and that is forced.** `server.js`
|
||||
// seeds before it requires `app.js`, and requiring `app.js` is what scans the
|
||||
// volume and runs the loader — so at the moment core seeds its own templates,
|
||||
// no module has registered anything.
|
||||
//
|
||||
// **After the reconcile and before `onBoot`**, both deliberately: `disabled`
|
||||
// is now known, so a module the operator switched off is skipped rather than
|
||||
// having its rules quietly written; and a module that warms a cache in
|
||||
// `onBoot` may assume its rules and bodies exist by then.
|
||||
//
|
||||
// Failed modules are skipped for the stronger reason. A module whose require
|
||||
// or schema replay failed has registered nothing anyway — but one whose ROW
|
||||
// says `startup_failed` may have registered before failing later, and seeding
|
||||
// content for a module that is about to answer 503 puts rows in the operator's
|
||||
// Rules screen for a thing that is not running.
|
||||
const skip = new Set([
|
||||
...disabled,
|
||||
...scanned.filter((m) => m.state === 'startup_failed').map((m) => m.id),
|
||||
])
|
||||
await safe('seeding module engagement content', () =>
|
||||
// eslint-disable-next-line global-require
|
||||
require('../engagement/moduleSeeds').seedModuleEngagement({ skip }))
|
||||
|
||||
for (const { id, hook, ctx } of loader.bootable()) {
|
||||
try {
|
||||
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the
|
||||
|
||||
@@ -228,6 +228,39 @@ function buildCtx(id, moduleRoot) {
|
||||
emit: (triggerId, envelope) => {
|
||||
engagementEmit.emit(id, triggerId, envelope)
|
||||
},
|
||||
// EVENTS.md §L, and the resource ledger (Phase 8). "On reconnect the runner
|
||||
// asks each ledgered resource's module to reconcile" — and this is how the
|
||||
// runner learns there has BEEN a reconnect.
|
||||
//
|
||||
// **Core cannot decide when to call this, and that is the contract rather
|
||||
// than a gap.** §F: core has no concept of the game being up, because a
|
||||
// module with six sidecars cannot answer that question in the singular. So
|
||||
// the module says so, when it sees its own — module-uo already watches
|
||||
// `bootId` to tell a shard restart from a sidecar reconnect, which is
|
||||
// exactly the moment a ledger of live spawns has become a claim about a
|
||||
// world that no longer exists.
|
||||
//
|
||||
// `id` is bound here and never taken from the arguments, like `emit` and
|
||||
// `teams.activity.push` before it: a module reconciles its OWN ledger, and
|
||||
// without the binding this would be a way to have core mark another
|
||||
// module's resources orphaned.
|
||||
//
|
||||
// Fire-and-forget and returns undefined, for the third time and the same
|
||||
// reason: this is called from inside a connection handler, and there is
|
||||
// nothing a module could correctly do with a failure of core's bookkeeping.
|
||||
reconcile: () => {
|
||||
// eslint-disable-next-line global-require
|
||||
require('../events/cleanup')
|
||||
.reconcileModule(id)
|
||||
.then(
|
||||
(summary) => {
|
||||
if (summary && summary.orphaned) {
|
||||
log.warn('event resources orphaned on reconcile', { module: id, ...summary })
|
||||
}
|
||||
},
|
||||
(err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) },
|
||||
)
|
||||
},
|
||||
},
|
||||
// The in-app sink (§5.1) — a module writing the inbox directly, without a
|
||||
// rule. Live from Phase 7; it threw until the `user_notifications` table
|
||||
@@ -356,6 +389,55 @@ function buildApi(record) {
|
||||
once('registerAudiences')
|
||||
record.staged.registerAudiences(audiences)
|
||||
},
|
||||
// What the module SHIPS behind those two — its message bodies and its
|
||||
// seeded rules (API 1.9.0, ENGAGEMENT.md Phase 11b decision 7). `once` for
|
||||
// the same reason again, and here it is load-bearing rather than tidy: a
|
||||
// rule belongs to exactly one named group, and merging two calls would make
|
||||
// "which group is this rule in" — the question the one-shot guard answers —
|
||||
// unanswerable.
|
||||
//
|
||||
// Data only. Nothing on the object is a function and nothing on it reaches a
|
||||
// recipient: seeding writes rows that are `enabled = 0`, and a module still
|
||||
// cannot send mail (§1.2).
|
||||
registerEngagementSeeds(seeds) {
|
||||
once('registerEngagementSeeds')
|
||||
record.staged.registerEngagementSeeds(seeds)
|
||||
},
|
||||
// The event contract (API 1.10.0, EVENTS.md §F). **This is the seam Phase 1
|
||||
// built and did not open**: `registerEventActions` has staged core's three
|
||||
// actions on every boot since then and no module could reach it, because
|
||||
// this facade had no method that delegated. The four lines below are what
|
||||
// Phase 7 ships — core has been going through the same door for six phases,
|
||||
// so the registry a module now reaches is one that has been exercised on
|
||||
// every boot rather than one whose first registrant is a stranger.
|
||||
//
|
||||
// `once` on all four, for the reason every batch registration above takes
|
||||
// it: a batch is a module's complete statement about what it declares, and a
|
||||
// second call is a module changing its mind halfway through `register()`
|
||||
// rather than adding to it.
|
||||
//
|
||||
// The four id spaces are separate and the loader does not police that —
|
||||
// `registries.apply()` does, per space. An action names a VERB, a budget
|
||||
// names a RESOURCE, a lease names a VALUE and an option source names a
|
||||
// CATALOG, so `uo.creatures` may legitimately appear in more than one of
|
||||
// them and reading that as a collision would forbid the most natural set of
|
||||
// names a module will ever write.
|
||||
registerEventActions(actions) {
|
||||
once('registerEventActions')
|
||||
record.staged.registerEventActions(actions)
|
||||
},
|
||||
registerEventBudgets(budgets) {
|
||||
once('registerEventBudgets')
|
||||
record.staged.registerEventBudgets(budgets)
|
||||
},
|
||||
registerEventLeases(leases) {
|
||||
once('registerEventLeases')
|
||||
record.staged.registerEventLeases(leases)
|
||||
},
|
||||
registerEventOptionSources(sources) {
|
||||
once('registerEventOptionSources')
|
||||
record.staged.registerEventOptionSources(sources)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,60 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.10.0 — the event contract opens to modules: `api.registerEventActions`,
|
||||
// `api.registerEventBudgets`, `api.registerEventLeases` and
|
||||
// `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md
|
||||
// Phase 7). Four names, and only one of them is new machinery: the ACTION
|
||||
// registry has staged core's `core.announce`, `core.wait` and `core.cue` on every
|
||||
// boot since Events Phase 1, and `loader.js` simply had no method that delegated
|
||||
// to it. What Phase 7 adds is the facade, the three declarations beside it, and
|
||||
// the fail-closed rule they exist for — a `cost()` naming a dimension no module
|
||||
// registered is REFUSED at save, at the dry run and at dispatch, so a module
|
||||
// cannot spend a budget it did not declare.
|
||||
//
|
||||
// Additions only, so minor: a module written against 1.9.0 registers no actions
|
||||
// and the deployment simply has fewer verbs an event can use. That is §F's own
|
||||
// posture stated as a version rule — core with no module installed is still an
|
||||
// event engine that can announce, wait, cue a human and publish results.
|
||||
//
|
||||
// **A lease is DECLARED here and acquired by nothing.** Core owns a lease's
|
||||
// duration and its conflict check, and both live in the resource ledger, which is
|
||||
// Phase 8's. It is in 1.10.0 rather than in 1.11.0 so that the module contract is
|
||||
// one version a module author reads once, not two.
|
||||
//
|
||||
// 1.9.0 - a sixth registration call: `api.registerEngagementSeeds({ templates,
|
||||
// ruleGroups })` (docs/website/ENGAGEMENT.md Phase 11b, decision 7). A module
|
||||
// could declare a trigger from 1.7.0 and could never say what the mail should
|
||||
// READ like: `templateSeeds.js` and `coreRules.js` are core files with core
|
||||
// arrays in them, so a module's notification was core's generic body or nothing.
|
||||
// Additions only, so minor: every module written against 1.8.0 keeps working and
|
||||
// simply seeds nothing.
|
||||
//
|
||||
// **What a module has to know about it beyond the new name**, because the two
|
||||
// halves behave differently on purpose:
|
||||
//
|
||||
// - **Templates are re-ensured on every boot**, under `seed_key` /
|
||||
// `seed_version` / `customized` - so bumping a body's `seedVersion` reaches
|
||||
// every deployment except the ones where an operator edited that row, and a
|
||||
// template added in a later module version reaches everyone.
|
||||
// - **Rules are one-shot, per named GROUP.** Re-ensuring one would resurrect a
|
||||
// rule an operator deleted and reset one they enabled, so each group carries
|
||||
// its own settings guard. A rule appended to an existing group therefore
|
||||
// reaches FRESH INSTALLS ONLY; one that must reach deployments already
|
||||
// stamped takes a new group key. That is 11a's seed-key finding as an API
|
||||
// rather than as a warning, and the module makes the choice knowingly.
|
||||
//
|
||||
// Two things it deliberately does not permit. A seeded rule is always
|
||||
// `enabled = 0` - it is not a parameter - which is Q3's invariant surviving
|
||||
// contact with the largest seed set in the workstream. And a module may not mark
|
||||
// a template `protected`: that flag means "the system breaks without this body",
|
||||
// which is true of a password reset and of nothing a module ships, and a module
|
||||
// setting it would take an operator's delete button away.
|
||||
//
|
||||
// It runs from `modules/lifecycle.js` `boot()` rather than `seedDefaults()`, and
|
||||
// that is forced rather than chosen: core seeds before `app.js` is required, and
|
||||
// requiring `app.js` is what runs the loader.
|
||||
|
||||
// 1.8.0 - a seventh value in the audience ceiling lattice: `admin`, a child of
|
||||
// `staff` (docs/website/ENGAGEMENT.md Phase 11, decision 1). A module may now
|
||||
// declare `ceiling: 'admin'` on a trigger or an audience, so the set of values
|
||||
@@ -101,6 +155,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.8.0'
|
||||
const MODULE_API_VERSION = '1.10.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -34,6 +34,7 @@ const templates = require('../../../model/engagement/engagementTemplates.model')
|
||||
const sendsDb = require('../../../model/engagement/engagementSends.db')
|
||||
const suppressionsDb = require('../../../model/engagement/engagementSuppressions.db')
|
||||
const suppressions = require('../../../engagement/suppressions')
|
||||
const retention = require('../../../model/engagement/engagementRetention.model')
|
||||
|
||||
// The lattice, flattened for a client: for each ceiling, the ones a rule may
|
||||
// choose under it. Served with the catalog rather than hardcoded in the admin
|
||||
@@ -496,12 +497,18 @@ exports.listSends = async (req, res, next) => {
|
||||
// human in the loop, and without a way back a mistyped-then-corrected mailbox is
|
||||
// silenced permanently.
|
||||
//
|
||||
// **The list returns `address_masked`, never `address_hash`.** The send log route
|
||||
// above strips the hash for a stated reason — shipping a sha256 of every address
|
||||
// on the deployment to a browser is an offline dictionary attack waiting to be
|
||||
// run — and the same reasoning applies twice over here, where the rows are
|
||||
// exactly the addresses somebody would most want to confirm. The mask is what an
|
||||
// operator can act on and is not reversible.
|
||||
// **The list DOES return `address_hash`, and Phase 14 reversed a Phase 9
|
||||
// decision to get there** (org lead, 2026-09-01). Phase 9 stripped it on the
|
||||
// grounds that a sha256 of every address on the deployment is an offline
|
||||
// dictionary attack waiting to be run, and left the only way out of the table a
|
||||
// `window.prompt` asking the operator to retype the full address — which they do
|
||||
// not have, because the screen shows a mask. The trade taken: this route is
|
||||
// admin-only and an admin can already suppress and unsuppress any address they
|
||||
// can name, so the hash grants them no capability they lack; what it buys is a
|
||||
// Lift button on the row the operator is actually looking at. The send log route
|
||||
// above still strips its hash, because nothing there needs to act on a row.
|
||||
//
|
||||
// The mask remains what is DISPLAYED. The hash is a handle, never rendered.
|
||||
|
||||
/** GET /api/v1/admin/engagement/suppressions */
|
||||
exports.listSuppressions = async (req, res, next) => {
|
||||
@@ -526,7 +533,7 @@ exports.listSuppressions = async (req, res, next) => {
|
||||
suppressionsDb.countsByReason(),
|
||||
])
|
||||
res.json({
|
||||
suppressions: rows.map(({ address_hash: _hash, ...row }) => row),
|
||||
suppressions: rows,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
@@ -595,3 +602,90 @@ exports.deleteSuppression = async (req, res, next) => {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/engagement/suppressions/by-hash/:hash
|
||||
*
|
||||
* The per-row Lift button (Phase 14). Same effect as the route above and a
|
||||
* different input: the operator is looking at a masked row and does not know the
|
||||
* address, so the only thing they can act on is the row's own handle.
|
||||
*
|
||||
* **The hash still goes in the path and that is safe where an address is not.**
|
||||
* The objection to a path parameter above is that an access log, a browser
|
||||
* history and every proxy in front of the deployment would capture a real
|
||||
* person's address; a sha256 that is already only ever served to an admin
|
||||
* session leaks nothing further by being logged.
|
||||
*
|
||||
* A 404 rather than a 200 when nothing matched, so a stale screen (two admins,
|
||||
* one list, one already lifted) tells the operator rather than claiming success.
|
||||
*/
|
||||
exports.deleteSuppressionByHash = async (req, res, next) => {
|
||||
try {
|
||||
const hash = typeof req.params.hash === 'string' ? req.params.hash.trim().toLowerCase() : ''
|
||||
// Validated in shape rather than trusted: this value reaches a WHERE clause,
|
||||
// and a 64-character hex string is the only thing this column ever holds.
|
||||
if (!/^[0-9a-f]{64}$/.test(hash)) {
|
||||
return res.status(400).json({ message: 'Not a suppression handle' })
|
||||
}
|
||||
const channel = typeof req.query.channel === 'string' && req.query.channel
|
||||
? req.query.channel
|
||||
: 'email'
|
||||
const removed = await suppressionsDb.remove(hash, channel)
|
||||
if (!removed) return res.status(404).json({ message: 'That address is not suppressed' })
|
||||
res.json({ removed: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retention (Phase 14) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Three horizons on one screen, because "what does this deployment keep" is one
|
||||
// question. The send-log horizon is the reason this is a screen at all rather
|
||||
// than the invisible settings row `team_activity` and `user_notifications` each
|
||||
// use: it changes what an operator-facing page is able to show, so an operator
|
||||
// has to be able to see and set it.
|
||||
|
||||
/** GET /api/v1/admin/engagement/retention */
|
||||
exports.getRetention = async (req, res, next) => {
|
||||
try {
|
||||
const policy = await retention.get()
|
||||
// The guard travels with the policy rather than only being logged at 3am by
|
||||
// the worker: the screen that can fix a too-short cooldown horizon is the one
|
||||
// that has to say it is too short.
|
||||
const cooldownCheck = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
res.json({
|
||||
retention: policy,
|
||||
limits: retention.HORIZONS,
|
||||
longestCooldownSeconds: cooldownCheck.longestCooldownSeconds,
|
||||
warnings: cooldownCheck.ok ? [] : [cooldownCheck.message],
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/engagement/retention
|
||||
*
|
||||
* A sparse PUT: only the horizons present in the body are written, so a screen
|
||||
* saving one select does not have to round-trip the other two and cannot
|
||||
* clobber a value another admin changed between load and save. Out-of-range is
|
||||
* a 400 rather than a clamp — silently storing something other than what was
|
||||
* typed would leave the screen describing a policy the deployment is not running.
|
||||
*/
|
||||
exports.putRetention = async (req, res, next) => {
|
||||
try {
|
||||
const policy = await retention.set(req.body || {}, req.user?.id ?? null)
|
||||
const cooldownCheck = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
res.json({
|
||||
retention: policy,
|
||||
limits: retention.HORIZONS,
|
||||
longestCooldownSeconds: cooldownCheck.longestCooldownSeconds,
|
||||
warnings: cooldownCheck.ok ? [] : [cooldownCheck.message],
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.status === 400) return res.status(400).json({ message: err.message })
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,4 +381,48 @@ engagementRouter.delete(
|
||||
controller.deleteSuppression,
|
||||
)
|
||||
|
||||
engagementRouter.delete(
|
||||
'/suppressions/by-hash/:hash',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Lift a suppression by its row handle'
|
||||
// #swagger.description = 'The per-row Lift button (Phase 14). Same effect as the route above, different input: the screen shows a mask, so the operator does not know the address and can only act on the row handle the list gives them. The handle IS safe in the path where an address is not - it is a sha256 already served only to an admin session, so an access log or proxy that captures it learns nothing new. 404 rather than 200 when nothing matched, so a stale screen (two admins, one list) says so instead of claiming success.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['hash'] = { in: 'path', description: 'The address_hash the list returns for that row, 64 hex characters', required: true, schema: { type: 'string' } }
|
||||
// #swagger.parameters['channel'] = { in: 'query', description: 'Defaults to email', required: false, schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Not a suppression handle', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.deleteSuppressionByHash,
|
||||
)
|
||||
|
||||
// ── Retention (Phase 14) ───────────────────────────────────────────────────
|
||||
|
||||
engagementRouter.get(
|
||||
'/retention',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Read the engagement retention policy'
|
||||
// #swagger.description = 'The three horizons the nightly sweep uses, in days, with the bounds each is validated against. `engagement_suppressions` is deliberately absent: a suppression is a standing decision and does not expire, because ageing out a hard bounce re-mails an address that already bounced. `warnings` carries the one check that cannot be a static bound - a cooldown horizon shorter than the longest cooldown on an ENABLED rule, which would let that rule send twice.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The current policy', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", properties: { sends: { type: "integer" }, cooldowns: { type: "integer" }, outbox: { type: "integer" } } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.getRetention,
|
||||
)
|
||||
|
||||
engagementRouter.put(
|
||||
'/retention',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Set the engagement retention policy'
|
||||
// #swagger.description = 'Sparse: only the horizons named in the body are written, so saving one select cannot clobber a value another admin changed between load and save. Out of range is a 400 rather than a clamp - storing something other than what was typed would leave the screen describing a policy the deployment is not running. The floors are not UI niceties: below 2 days a pruned cooldown row makes the next fire a FIRST fire (a duplicate send), and the send log is counted by the per-rule hourly ceiling.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { sends: { type: "integer", nullable: true }, cooldowns: { type: "integer", nullable: true }, outbox: { type: "integer", nullable: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The policy as it now stands', content: { "application/json": { schema: { type: "object", properties: { retention: { type: "object", additionalProperties: { type: "integer" } }, limits: { type: "object", additionalProperties: true }, longestCooldownSeconds: { type: "integer" }, warnings: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A horizon was not a whole number of days, or was out of range', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.putRetention,
|
||||
)
|
||||
|
||||
module.exports = engagementRouter
|
||||
|
||||
822
server/src/router/v1/admin/events.controller.js
Normal file
822
server/src/router/v1/admin/events.controller.js
Normal file
@@ -0,0 +1,822 @@
|
||||
// ── Admin: events ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md § API surface, Phase 1. Definitions CRUD, publish, archive, the
|
||||
// action catalog and the run reads.
|
||||
//
|
||||
// This file reads ids out of URLs and shapes responses; it validates nothing.
|
||||
// Every decision lives in `model/events/*.model.js` and in `events/spec.js`, so
|
||||
// a definition arriving from a future import or a restore gets the same answer
|
||||
// this screen does.
|
||||
//
|
||||
// **Phase 3 added the live run controls** at the bottom of this file: pause,
|
||||
// resume, cancel, and a step's confirm, skip and retry. `advance` joined them in
|
||||
// Phase 5, the action switchboard in Phase 6, and **`cleanup` in Phase 8** —
|
||||
// each when the phase that gave it something to act on landed, and each absent
|
||||
// rather than stubbed until then, 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.
|
||||
// Nothing in the § API surface table is absent any more.
|
||||
|
||||
const registries = require('../../../modules/registries')
|
||||
const spec = require('../../../events/spec')
|
||||
const conditionGrammar = require('../../../engagement/conditions')
|
||||
const definitionsDb = require('../../../model/events/eventDefinitions.db')
|
||||
const definitions = require('../../../model/events/eventDefinitions.model')
|
||||
const versionsDb = require('../../../model/events/eventVersions.db')
|
||||
const seriesDb = require('../../../model/events/eventSeries.db')
|
||||
const series = require('../../../model/events/eventSeries.model')
|
||||
const calendarModel = require('../../../model/events/eventCalendar.model')
|
||||
const eventRunner = require('../../../utils/eventRunner')
|
||||
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')
|
||||
const settingsDb = require('../../../model/events/eventActionSettings.db')
|
||||
const authorize = require('../../../events/authorize')
|
||||
const price = require('../../../events/price')
|
||||
|
||||
const asId = (raw) => {
|
||||
const n = Number(raw)
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape a definition takes on the wire.
|
||||
*
|
||||
* Explicit rather than the row, like every other admin surface here: the row
|
||||
* carries `created_by`, `updated_by` and the joined series columns, and a
|
||||
* response that spreads it is a response that gains a column the day somebody
|
||||
* adds one.
|
||||
*/
|
||||
const shapeDefinition = (d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
slug: d.slug,
|
||||
summary: d.summary,
|
||||
body: d.body,
|
||||
imageUrl: d.image_url,
|
||||
ownerModule: d.owner_module,
|
||||
state: d.state,
|
||||
currentVersionId: d.current_version_id,
|
||||
currentVersion: d.current_version,
|
||||
// §K's gate, rendered where it can still be acted on. `null` on a draft --
|
||||
// there is no version to have verified -- and a date once a dry run has passed
|
||||
// against the published one.
|
||||
currentVersionVerifiedAt: d.current_version_verified_at || null,
|
||||
seriesId: d.series_id,
|
||||
seriesName: d.series_name,
|
||||
seriesOrder: d.series_order,
|
||||
concurrencyKey: d.concurrency_key,
|
||||
graceSeconds: d.grace_seconds,
|
||||
timezone: d.timezone,
|
||||
// Whether the public calendar announces it (Phase 14a). Not whether it may
|
||||
// run — an unlisted event schedules and runs exactly as a listed one does,
|
||||
// and is on THIS screen either way.
|
||||
listed: Boolean(d.listed),
|
||||
spec: d.spec,
|
||||
createdAt: d.created_at,
|
||||
updatedAt: d.updated_at,
|
||||
})
|
||||
|
||||
const shapeRun = (r) => ({
|
||||
id: r.id,
|
||||
definitionId: r.definition_id,
|
||||
definitionTitle: r.definition_title,
|
||||
definitionSlug: r.definition_slug,
|
||||
versionId: r.version_id,
|
||||
version: r.version_number,
|
||||
scope: r.scope,
|
||||
status: r.status,
|
||||
health: r.health,
|
||||
cleanupStatus: r.cleanup_status,
|
||||
currentPhase: r.current_phase,
|
||||
scheduledFor: r.scheduled_for,
|
||||
timezone: r.timezone,
|
||||
concurrencyKey: r.concurrency_key,
|
||||
params: r.params,
|
||||
rehearsal: r.rehearsal,
|
||||
startedAt: r.started_at,
|
||||
endedAt: r.ended_at,
|
||||
// When the results table was ranked and published (Phase 10). On the LIST as
|
||||
// well as the console, because "which of last month's events still have no
|
||||
// published results" is a question about a list.
|
||||
resultsPublishedAt: r.results_published_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) => ({
|
||||
id: s.id,
|
||||
runId: s.run_id,
|
||||
phase: s.phase,
|
||||
seq: s.seq,
|
||||
actionId: s.action_id,
|
||||
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,
|
||||
idempotencyKey: s.idempotency_key,
|
||||
lastError: s.last_error,
|
||||
startedAt: s.started_at,
|
||||
finishedAt: s.finished_at,
|
||||
})
|
||||
|
||||
/** GET /api/v1/admin/events */
|
||||
exports.list = async (req, res) => {
|
||||
const state = ['draft', 'ready', 'archived'].includes(req.query.state) ? req.query.state : null
|
||||
const rows = await definitionsDb.list({ state })
|
||||
res.json({ events: rows.map(shapeDefinition) })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/catalog
|
||||
*
|
||||
* The registered actions, their param schemas, their risk classes and the
|
||||
* vocabularies over both — served from the registries, so there is no table
|
||||
* behind it and a module that was uninstalled simply stops appearing. Same
|
||||
* argument the engagement trigger catalog makes: the editor offers exactly the
|
||||
* set the save path checks against, so the two cannot drift.
|
||||
*
|
||||
* Budget dimensions are absent, and that is Phase 1 being honest rather than an
|
||||
* omission: `registerEventBudgets` is Phase 7's and nothing declares one yet.
|
||||
*/
|
||||
exports.catalog = (_req, res) => {
|
||||
res.json({
|
||||
actions: registries.allEventActions(),
|
||||
risks: registries.ACTION_RISKS,
|
||||
reversible: registries.ACTION_REVERSIBLE,
|
||||
paramTypes: registries.ACTION_PARAM_TYPES,
|
||||
onFailure: spec.ON_FAILURE,
|
||||
onFailureByRisk: spec.ON_FAILURE_BY_RISK,
|
||||
scheduleKinds: spec.SCHEDULE_KINDS,
|
||||
// **The trigger catalog is served here too, and not borrowed from
|
||||
// `/admin/engagement/triggers`** (Phase 5). §C's claim is that the trigger
|
||||
// catalog a module already ships IS the catalog of things that can advance a
|
||||
// phase — so it is the same registry, read twice. What differs is who may
|
||||
// read it: the engagement route is `adminOnly`, and event definitions are
|
||||
// authored by `admin` AND `editor`. Pointing this editor at that route would
|
||||
// have left an editor writing a trigger id from memory into a field the save
|
||||
// path then refused.
|
||||
//
|
||||
// Each declaration is reduced to what the gate form needs — id, label and
|
||||
// the variables a `where` may name. Everything else on a trigger (its
|
||||
// audience, its ceiling, its subject key) is about who gets MAILED, which is
|
||||
// a different question and not this screen's.
|
||||
triggers: registries.allTriggers().map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
owner: t.owner,
|
||||
variables: (t.variables || []).map((v) => ({
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
required: v.required,
|
||||
description: v.description,
|
||||
})),
|
||||
})),
|
||||
operators: conditionGrammar.vocabulary(),
|
||||
advanceKinds: spec.ADVANCE_KINDS,
|
||||
// **The other three registrations of the module contract** (Phase 7). Served
|
||||
// beside the actions rather than on three routes of their own, because the
|
||||
// step editor needs all four to render one step: the action says what params
|
||||
// it takes, a param's `source` names an option source, and a cap the editor
|
||||
// shows is a budget's label and unit. Four requests to draw one form would
|
||||
// be four chances for the screen to render half of it.
|
||||
//
|
||||
// Each is already stripped of its callables by the registry (`resolve`,
|
||||
// `read`, `apply`, `restore`) — the same rule that keeps `perform` off an
|
||||
// action here. A source's VALUES are not in this payload either: they are a
|
||||
// request of their own (`/options/:sourceId`), because a source can be slow,
|
||||
// can fail, and would otherwise take the whole catalog down with it.
|
||||
budgets: registries.allEventBudgets(),
|
||||
leases: registries.allEventLeases(),
|
||||
optionSources: registries.allEventOptionSources(),
|
||||
limits: {
|
||||
maxPhases: spec.MAX_PHASES,
|
||||
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
|
||||
maxSteps: spec.MAX_STEPS,
|
||||
defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
|
||||
maxAdvanceCount: spec.MAX_ADVANCE_COUNT,
|
||||
maxAfterSeconds: spec.MAX_AFTER_SECONDS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const shapeSeries = (s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
description: s.description,
|
||||
ordering: s.ordering,
|
||||
definitionCount: Number(s.definition_count || 0),
|
||||
})
|
||||
|
||||
/** GET /api/v1/admin/events/series */
|
||||
exports.listSeries = async (_req, res) => {
|
||||
const rows = await seriesDb.list()
|
||||
res.json({ series: rows.map(shapeSeries) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/series */
|
||||
exports.createSeries = async (req, res) => {
|
||||
const result = await series.create(req.body, req.user?.id)
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.series.created',
|
||||
detail: { id: result.series.id, name: result.series.name },
|
||||
})
|
||||
res.status(201).json({ series: shapeSeries(result.series) })
|
||||
}
|
||||
|
||||
/** PUT /api/v1/admin/events/series/:seriesId */
|
||||
exports.updateSeries = async (req, res) => {
|
||||
const id = asId(req.params.seriesId)
|
||||
if (!id) return res.status(404).json({ error: 'no such series' })
|
||||
const result = await series.update(id, req.body, req.user?.id)
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.series.updated', detail: { id, name: result.series.name } })
|
||||
res.json({ series: shapeSeries(result.series) })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/events/series/:seriesId
|
||||
*
|
||||
* `detached` is in the response because the delete is not confined to the row:
|
||||
* `series_id` is `ON DELETE SET NULL`, so definitions that belonged to the arc
|
||||
* survive it without one. Saying how many is the difference between an operator
|
||||
* knowing and an operator finding out.
|
||||
*/
|
||||
exports.deleteSeries = async (req, res) => {
|
||||
const id = asId(req.params.seriesId)
|
||||
if (!id) return res.status(404).json({ error: 'no such series' })
|
||||
const result = await series.remove(id)
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.series.deleted', detail: { id, detached: result.detached } })
|
||||
res.json({ ok: true, detached: result.detached })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/calendar
|
||||
*
|
||||
* `from` and `to` are UTC instants and the caller supplies both: a month grid
|
||||
* knows its own boundaries in the viewer's zone, and having the server guess
|
||||
* them would be the server guessing the viewer's zone.
|
||||
*/
|
||||
exports.calendar = async (req, res) => {
|
||||
const result = await calendarModel.calendar({
|
||||
from: req.query.from,
|
||||
to: req.query.to,
|
||||
status: req.query.status || null,
|
||||
scope: req.query.scope || null,
|
||||
seriesId: asId(req.query.seriesId),
|
||||
horizonDays: eventRunner.HORIZON_DAYS,
|
||||
})
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
res.json({
|
||||
window: result.window,
|
||||
horizon: result.horizon,
|
||||
horizonDays: eventRunner.HORIZON_DAYS,
|
||||
entries: result.entries,
|
||||
truncated: result.truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/events/runs */
|
||||
exports.listRuns = async (req, res) => {
|
||||
const rows = await runsDb.list({
|
||||
definitionId: asId(req.query.definitionId),
|
||||
status: req.query.status || null,
|
||||
limit: req.query.limit,
|
||||
})
|
||||
res.json({ runs: rows.map(shapeRun) })
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/events/runs/:runId */
|
||||
exports.getRun = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
if (!runId) return res.status(400).json({ error: 'bad run id' })
|
||||
const found = await runs.detail(runId)
|
||||
if (!found) return res.status(404).json({ error: 'no such run' })
|
||||
return res.json({
|
||||
run: shapeRun(found.run),
|
||||
steps: found.steps.map(shapeStep),
|
||||
counts: found.counts,
|
||||
// Already rendered in the condition builder's own words (Phase 5). See
|
||||
// `eventRuns.model.detail` for why the sentence is built here and not in
|
||||
// the browser.
|
||||
gates: found.gates,
|
||||
// The caps this run was given and what it has spent (Phase 6). Copied into
|
||||
// the run when it was created, so it answers "what was THIS run allowed"
|
||||
// rather than "what is allowed now" — which is the question that survives
|
||||
// an admin moving a switch tomorrow.
|
||||
budget: found.budget,
|
||||
// The resource ledger (Phase 8): everything this run created or borrowed, and
|
||||
// what became of each. The WHOLE ledger, reverted rows included — "how much
|
||||
// did last night's invasion spawn, and did all of it come back" is one
|
||||
// question with two halves, and a list of only the failures answers neither.
|
||||
resources: found.resources,
|
||||
unresolvedResources: found.unresolvedResources,
|
||||
// Who took part, best first (Phase 10). `rank` is null on every row until
|
||||
// `core.results.publish` has ranked them, which is what lets the console
|
||||
// show a collected-but-unpublished run as exactly that rather than
|
||||
// inventing an ordering nobody settled.
|
||||
participants: found.participants,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/events/runs/:runId/log */
|
||||
exports.getRunLog = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
if (!runId) return res.status(400).json({ error: 'bad run id' })
|
||||
const run = await runsDb.getById(runId)
|
||||
if (!run) return res.status(404).json({ error: 'no such run' })
|
||||
const lines = await logDb.listForRun(runId, { limit: req.query.limit })
|
||||
return res.json({
|
||||
log: lines.map((l) => ({
|
||||
id: l.id,
|
||||
stepId: l.step_id,
|
||||
kind: l.kind,
|
||||
phase: l.phase,
|
||||
detail: l.detail,
|
||||
at: l.at,
|
||||
})),
|
||||
kinds: logDb.KINDS,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/events/:id */
|
||||
exports.get = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const row = await definitionsDb.getById(id)
|
||||
if (!row) return res.status(404).json({ error: 'no such event definition' })
|
||||
return res.json({ event: shapeDefinition(row) })
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/events/:id/versions */
|
||||
exports.listVersions = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const row = await definitionsDb.getById(id)
|
||||
if (!row) return res.status(404).json({ error: 'no such event definition' })
|
||||
const rows = await versionsDb.listForDefinition(id)
|
||||
return res.json({
|
||||
versions: rows.map((v) => ({
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
publishedAt: v.published_at,
|
||||
publishedBy: v.published_by,
|
||||
publishedByUsername: v.published_by_username,
|
||||
current: v.id === row.current_version_id,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events */
|
||||
exports.create = async (req, res) => {
|
||||
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.created',
|
||||
detail: { id: result.id, title: result.definition.title },
|
||||
})
|
||||
return res.status(201).json({ event: shapeDefinition(result.definition) })
|
||||
}
|
||||
|
||||
/** PUT /api/v1/admin/events/:id */
|
||||
exports.update = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.updated',
|
||||
detail: { id, title: result.definition.title },
|
||||
})
|
||||
return res.json({ event: shapeDefinition(result.definition) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/:id/publish */
|
||||
exports.publish = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.publish(id, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.published',
|
||||
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
|
||||
})
|
||||
return res.json({
|
||||
event: shapeDefinition(result.definition),
|
||||
version: result.version,
|
||||
versionId: result.versionId,
|
||||
// How many already-materialised occurrences moved to this version. The
|
||||
// screen says so, because "my fix did not reach next Friday" is otherwise
|
||||
// found out on Friday.
|
||||
repinned: result.repinned,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/events/:id/verify — the dry run.
|
||||
*
|
||||
* `admin, editor` rather than `admin` (§ API surface): a dry run dispatches
|
||||
* nothing and changes nothing, and the author who wrote the definition is
|
||||
* exactly who should be able to price it against the caps before asking an
|
||||
* admin to publish it.
|
||||
*
|
||||
* **A report with findings is a 200, not a 400.** The request succeeded; the
|
||||
* plan has problems. Answering 4xx would make "this event asks for 45 creatures
|
||||
* and you allow 30" indistinguishable to the client from "you sent a bad event
|
||||
* id", and the whole value of the screen is rendering the findings.
|
||||
*/
|
||||
exports.verify = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.verify(id, req.user)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.verified',
|
||||
detail: {
|
||||
id,
|
||||
target: result.target,
|
||||
versionId: result.versionId,
|
||||
passed: result.report.ok,
|
||||
findings: result.report.findings.length,
|
||||
},
|
||||
})
|
||||
return res.json({
|
||||
target: result.target,
|
||||
versionId: result.versionId,
|
||||
version: result.version,
|
||||
recorded: result.recorded,
|
||||
report: result.report,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/events/price — the live cap meter (Phase 13).
|
||||
*
|
||||
* `admin, editor`, exactly as the dry run is and for the same reason: an author
|
||||
* should be able to find out what their plan would cost before asking an admin
|
||||
* to commit the deployment to it.
|
||||
*
|
||||
* **The spec is in the BODY, not looked up by id**, and that is the whole point
|
||||
* of the route. The meter answers a question about the plan in the author's
|
||||
* hands — half-typed, unsaved, and quite possibly not publishable yet — so a
|
||||
* route that read the stored draft would be answering about a spec the author is
|
||||
* no longer looking at.
|
||||
*
|
||||
* It dispatches nothing, unlike `verify`, and it records nothing, unlike a dry
|
||||
* run that passes against a version — which is the stamp §K's unattended-start
|
||||
* gate reads. Those two absences are exactly what make it safe to call while
|
||||
* somebody is still typing.
|
||||
*
|
||||
* A body core cannot make sense of is a `400`; a plan that is over the caps is a
|
||||
* **200**, for the dry run's reason — *"this asks for 45 and you allow 30"* is
|
||||
* an answer, not a failed request.
|
||||
*
|
||||
* Not logged to the activity trail. It is a read that changes nothing and it
|
||||
* fires on a debounce while a form is edited; an audit line per keystroke would
|
||||
* bury the acts that matter under the act of looking.
|
||||
*/
|
||||
exports.price = async (req, res) => {
|
||||
const result = await price.priceSpec(req.body || {})
|
||||
if (!result.ok) return res.status(400).json({ error: result.error })
|
||||
return res.json({
|
||||
steps: result.steps,
|
||||
priced: result.priced,
|
||||
cost: result.cost,
|
||||
phases: result.phases,
|
||||
unpriced: result.unpriced,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/actions — the deployment's switchboard.
|
||||
*
|
||||
* Every registered action, each with the deployment's stored opinion of it or,
|
||||
* where there is none, **the default its risk class implies**. The default is
|
||||
* computed by `authorize.isEnabled` rather than here, because a screen that
|
||||
* worked out the posture for itself would be a second copy of the posture, and
|
||||
* the copy that drifts is always the one on the screen.
|
||||
*
|
||||
* `configured` says whether a row exists, which the client needs to tell "an
|
||||
* admin turned this on" from "this has always been on" — the same fact, arrived
|
||||
* at two ways, and only one of them is a decision somebody made.
|
||||
*/
|
||||
exports.actions = async (_req, res) => {
|
||||
const all = registries.allEventActions()
|
||||
const stored = await settingsDb.byIds(all.map((a) => a.id))
|
||||
return res.json({
|
||||
actions: all.map((a) => {
|
||||
const row = stored.get(a.id) || null
|
||||
const full = registries.eventAction(a.id)
|
||||
return {
|
||||
...a,
|
||||
enabled: authorize.isEnabled(full, row),
|
||||
configured: Boolean(row),
|
||||
changesWorld: authorize.changesWorld(full),
|
||||
// The dimensions this action can spend, so the screen can offer a cap
|
||||
// box per dimension — each now carrying the label and unit its
|
||||
// `registerEventBudgets` declaration gives it (Phase 7), because "30" on
|
||||
// a box is ambiguous in exactly the case that matters: 30 of what?
|
||||
//
|
||||
// `registered: false` says a dimension nobody declares, and it is shown
|
||||
// rather than filtered out: an action that prices an undeclared
|
||||
// dimension is REFUSED at save and at dispatch, so the screen must be
|
||||
// able to show the operator why their action will not run instead of
|
||||
// quietly listing one fewer box than the action has dimensions.
|
||||
dimensions: authorize.budgetsOf(full),
|
||||
caps: row?.caps || {},
|
||||
updatedAt: row?.updated_at || null,
|
||||
updatedBy: row?.updated_by_username || null,
|
||||
}
|
||||
}),
|
||||
// The rule the screen explains to the operator, served rather than written
|
||||
// into the client twice.
|
||||
worldChangingRisks: authorize.WORLD_CHANGING,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/catalog/options/:sourceId — the values behind a param's `source`.
|
||||
*
|
||||
* §F's *Param option sources* (Phase 7). Without it the step editor is a JSON
|
||||
* editor with better fonts: a landmark, a creature and an item are all "a string
|
||||
* the operator has to spell right", and an unattended world write scheduled with
|
||||
* a typo in it is the failure this whole feature exists to make unlikely.
|
||||
*
|
||||
* **A refusal is a 200 with `ok: false`, not a 4xx or a 5xx.** §F: a source that
|
||||
* cannot answer degrades its field to free text with a visible warning rather
|
||||
* than blocking the form. A 502 would be true about the module and wrong about
|
||||
* the screen — the operator very often knows the value they want to type, and an
|
||||
* authoring form that a sidecar outage can make unusable is a worse failure than
|
||||
* the typo the dropdown prevents. The client renders the `reason` beside the box.
|
||||
*
|
||||
* `admin, editor`, like the catalog and for the same argument: this is authoring
|
||||
* data, and an editor who can write the step must be able to see the values it
|
||||
* accepts. The registry answers it, so it names no game noun here.
|
||||
*/
|
||||
exports.options = async (req, res) => {
|
||||
// `q` is passed straight through and bounded by the registry, not here: the
|
||||
// registry is what every caller of a source goes through, and a bound written
|
||||
// on the route would be a bound the next caller does not have.
|
||||
const result = await registries.resolveOptionSource(String(req.params.sourceId || ''), {
|
||||
q: String(req.query.q || ''),
|
||||
})
|
||||
return res.json(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
|
||||
*
|
||||
* One action per request rather than the whole board: the board is rendered from
|
||||
* the registry and a whole-board PUT would have to say what an action MISSING
|
||||
* from the body means. On a screen listing what is registered right now, that is
|
||||
* "a module booted between the GET and the PUT", and answering it by writing a
|
||||
* default over an admin's stored choice is the kind of quiet data loss a sparse
|
||||
* write does not have.
|
||||
*/
|
||||
exports.saveAction = async (req, res) => {
|
||||
const actionId = String(req.body?.actionId || '')
|
||||
const action = registries.eventAction(actionId)
|
||||
if (!action) return res.status(404).json({ error: 'no module registers that action' })
|
||||
|
||||
if (typeof req.body?.enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be true or false' })
|
||||
}
|
||||
|
||||
// Caps are validated against the dimensions this action can actually spend.
|
||||
// A cap on a dimension it never names is not a harmless extra row — it is a
|
||||
// number an operator believes is protecting them, on a screen that would
|
||||
// render it back to them forever, bounding nothing.
|
||||
const known = new Set(authorize.dimensionsOf(action))
|
||||
const caps = {}
|
||||
for (const [dimension, raw] of Object.entries(req.body?.caps || {})) {
|
||||
if (raw === null || raw === '') continue
|
||||
if (!known.has(dimension)) {
|
||||
return res.status(400).json({ error: `"${action.id}" does not spend "${dimension}"` })
|
||||
}
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
return res.status(400).json({ error: `the cap for "${dimension}" must be a whole number of 0 or more` })
|
||||
}
|
||||
caps[dimension] = n
|
||||
}
|
||||
|
||||
const row = await settingsDb.put(actionId, { enabled: req.body.enabled, caps }, req.user.id)
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.action.configured',
|
||||
detail: { actionId, enabled: Boolean(req.body.enabled), caps },
|
||||
})
|
||||
return res.json({
|
||||
action: {
|
||||
id: actionId,
|
||||
enabled: Boolean(row.enabled),
|
||||
configured: true,
|
||||
caps: row.caps,
|
||||
updatedAt: row.updated_at,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/admin/events/:id — archive, never a hard delete */
|
||||
exports.archive = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.archive(id, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.definition.archived', detail: { id } })
|
||||
return res.json({ event: shapeDefinition(result.definition) })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/events/:id/runs
|
||||
*
|
||||
* Creates the occurrence. It stays `scheduled` until Phase 2's runner exists,
|
||||
* and the response says so through `pending: true` rather than by pretending
|
||||
* something started.
|
||||
*/
|
||||
exports.startRun = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await runs.create(
|
||||
id,
|
||||
{
|
||||
scope: req.body?.scope,
|
||||
scheduledFor: req.body?.scheduledFor,
|
||||
rehearsal: Boolean(req.body?.rehearsal),
|
||||
params: req.body?.params ?? null,
|
||||
},
|
||||
req.user.id,
|
||||
)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
if (result.created) {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.run.created',
|
||||
detail: {
|
||||
definitionId: id,
|
||||
runId: result.run.id,
|
||||
rehearsal: Boolean(req.body?.rehearsal),
|
||||
},
|
||||
})
|
||||
}
|
||||
return res.status(result.created ? 201 : 200).json({
|
||||
run: shapeRun(result.run),
|
||||
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/advance */
|
||||
exports.advanceRunPhase = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
if (!runId) return res.status(400).json({ error: 'bad run id' })
|
||||
const result = await controls.advancePhase(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.advanced',
|
||||
detail: { runId, phase: result.phase, reason: req.body?.reason || null },
|
||||
})
|
||||
return res.json({ run: shapeRun(result.run), phase: result.phase })
|
||||
}
|
||||
|
||||
/** 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' })
|
||||
// **`cleanup` defaults to true and has to be asked out of.** §L makes cancelling
|
||||
// WITHOUT cleanup the separate, admin-only, logged action, so an absent flag
|
||||
// must mean "give back what this run took" — the safe direction, and the one a
|
||||
// moderator's cancel at two in the morning takes without having to know the
|
||||
// flag exists.
|
||||
const withCleanup = req.body?.cleanup !== false
|
||||
const result = await controls.cancel(
|
||||
runId,
|
||||
{ reason: req.body?.reason, cleanup: withCleanup },
|
||||
req.user.id,
|
||||
{ isAdmin: req.user.role === 'admin' },
|
||||
)
|
||||
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,
|
||||
cleanup: result.cleanup,
|
||||
},
|
||||
})
|
||||
return res.json({
|
||||
run: shapeRun(result.run),
|
||||
cancelledSteps: result.cancelledSteps,
|
||||
cleanup: result.cleanup,
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/runs/:runId/cleanup */
|
||||
exports.cleanupRun = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
if (!runId) return res.status(400).json({ error: 'bad run id' })
|
||||
const result = await controls.cleanupRun(runId, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.run.cleaned', detail: { runId, ...result.summary } })
|
||||
// **A 200 whatever the sweep found.** The request succeeded; some resources may
|
||||
// still be out there, and answering 4xx would make "the shard refused to delete
|
||||
// three of these" indistinguishable from "you sent a bad run id" — the same
|
||||
// argument the dry run's findings make.
|
||||
return res.json({ run: shapeRun(result.run), summary: result.summary })
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
506
server/src/router/v1/admin/events.router.js
Normal file
506
server/src/router/v1/admin/events.router.js
Normal file
@@ -0,0 +1,506 @@
|
||||
// Admin · Events — definitions, versions, the action catalog and run reads
|
||||
// (EVENTS.md § API surface, Phase 1).
|
||||
//
|
||||
// Mounted at /api/v1/admin/events by admin/index.js, which has already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Every route below re-gates to the tier
|
||||
// EVENTS.md § API surface names for it.
|
||||
//
|
||||
// **The gates are the real ones from this phase, not placeholders.** §N2 decided
|
||||
// that publish and start are `admin` ONLY — a moderator keeps live control of a
|
||||
// run already in flight and nothing more — and the switchboard those gates will
|
||||
// eventually consult (`event_action_settings`, Phase 6) does not exist yet. They
|
||||
// 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. The live run controls landed in Phase 3 and are `admin`
|
||||
// + `moderator`, deliberately wider than start (§N2). `advance` arrived in Phase
|
||||
// 5; **`verify` and the action switchboard arrived in Phase 6** — `verify` at
|
||||
// `admin, editor` because a dry run dispatches nothing, and both halves of
|
||||
// `/actions` at `admin`, because §K puts the switchboard in the same row as the
|
||||
// world-changing actions it governs. **`cleanup` completed the set in Phase 8**,
|
||||
// and it is `admin` rather than admin+moderator for the same §K reason: it asks
|
||||
// core to write to the world again, which is not incident response. There is no
|
||||
// route in the § API surface table left absent.
|
||||
//
|
||||
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`,
|
||||
// `/calendar` and `/runs` are never read as an event id.
|
||||
//
|
||||
// **Phase 4 added the series writes and the calendar.** The series writes are
|
||||
// `admin, editor` rather than `admin`: naming an arc is authoring, and §N2's
|
||||
// narrow gate is about committing the deployment to a run. The calendar is a
|
||||
// staff read like every other read here.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const controller = require('./events.controller')
|
||||
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 ───────────
|
||||
|
||||
eventsRouter.get(
|
||||
'/catalog',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
|
||||
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.catalog,
|
||||
)
|
||||
|
||||
// ── Param option sources (Phase 7) ────────────────────────────────────────
|
||||
//
|
||||
// Nested UNDER `/catalog`, which is where the § API surface table has always put
|
||||
// it, and the nesting is the right shape rather than a formality: a source's
|
||||
// values are catalog data fetched on their own request, because a source can be
|
||||
// slow or down and must not take the catalog with it. It also puts the route
|
||||
// permanently out of `/:id`'s way — `/:id/anything` is one route away from being
|
||||
// added, and a source id read as an event id would 404 with the wrong noun.
|
||||
//
|
||||
// Staff, not `adminOnly`: this is authoring data, and §N2's narrow gate is about
|
||||
// committing the deployment to a run, not about seeing which landmarks exist.
|
||||
|
||||
eventsRouter.get(
|
||||
'/catalog/options/:sourceId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Resolve the values behind a param option source'
|
||||
// #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it. Phase 12b adds the optional `q`: a source whose catalog is larger than a dropdown can hold (the first is the spawner target, 6,707 entries against a 2,000 bound) narrows its answer by it, and one that ignores it answers exactly as before. `searchable` on the response says which is which, so the form renders a typeahead rather than a select.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// A single-key `schema` on purpose: swagger-autogen renders a two-key one as an
|
||||
// object schema whose properties are `type` and `maxLength`, which documents a
|
||||
// query parameter that takes a JSON object. The bound is stated in the description.
|
||||
/* #swagger.parameters['q'] = { in: 'query', description: 'Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.', required: false, schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.options,
|
||||
)
|
||||
|
||||
// ── The live cap meter (Phase 13) ──────────────────────────────────
|
||||
//
|
||||
// A literal path for the same reason `/actions` is one, and `admin, editor` for
|
||||
// the same reason `verify` is: it dispatches nothing and it prices an author's
|
||||
// own work.
|
||||
//
|
||||
// **It takes a spec rather than an id**, which is what separates it from the dry
|
||||
// run. A meter has to answer about the form as it stands, and the form is not
|
||||
// saved between keystrokes.
|
||||
|
||||
eventsRouter.post(
|
||||
'/price',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Price an unsaved spec against the per-run caps, dispatching nothing'
|
||||
// #swagger.description = 'EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { phases: { type: "array", items: { type: "object", properties: { key: { type: "string" }, steps: { type: "array", items: { type: "object", properties: { actionId: { type: "string" }, params: { type: "object", additionalProperties: true } } } } } } } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The draw per dimension, the draw per phase, and every step that could not be priced', content: { "application/json": { schema: { type: "object", properties: { steps: { type: "integer" }, priced: { type: "integer" }, cost: { type: "array", items: { type: "object", properties: { dimension: { type: "string" }, total: { type: "integer" }, cap: { type: "integer" }, from: { type: "string" }, over: { type: "boolean" } } } }, phases: { type: "array", items: { type: "object", additionalProperties: true } }, unpriced: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The body is over the spec size limits', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.price,
|
||||
)
|
||||
|
||||
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
|
||||
//
|
||||
// A literal path, so it is declared up here with `/catalog` rather than beside
|
||||
// the definition routes -- `/:id` would otherwise read `actions` as an event id.
|
||||
// Both halves are `adminOnly`: §K puts the action switchboard in the same row as
|
||||
// the world-changing actions it governs, because deciding what a deployment may
|
||||
// do at all is configuration that can break things, which is exactly the line
|
||||
// module-uo's split already draws.
|
||||
|
||||
eventsRouter.get(
|
||||
'/actions',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Which actions are enabled on this deployment, and their per-run caps'
|
||||
// #swagger.description = 'The deployment switchboard (EVENTS.md K, Phase 6). One entry per action registered on THIS boot, each carrying the deployment stored opinion of it or, where there is none, the default its risk class implies: change and irreversible actions arrive disabled, notify and inspect arrive enabled. `configured` says whether a row exists at all, which is how the screen tells "an admin turned this on" from "this has always been on". `dimensions` is what the action can spend, so the screen can offer one cap box per dimension. Nothing is seeded at boot: a deployment that has never opened this screen has no rows and behaves correctly.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Every registered action with its switch, its caps and the dimensions it can spend', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, worldChangingRisks: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.actions,
|
||||
)
|
||||
|
||||
eventsRouter.put(
|
||||
'/actions',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Enable or disable one action, and set its per-run caps'
|
||||
// #swagger.description = 'One action per request rather than the whole board, because the board is rendered from the registry and a whole-board write would have to decide what an action missing from the body means -- on a screen listing what is registered right now that is "a module booted between the read and the write", and writing a default over an admin stored choice is quiet data loss. A cap must name a dimension the action actually spends: a cap on a dimension it never names would be a number an operator believes is protecting them while it bounds nothing. Caps are copied into a run budget when the run is created, so moving a switch never changes what a run already in flight is allowed.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["actionId", "enabled"], properties: { actionId: { type: "string", example: "core.announce" }, enabled: { type: "boolean", example: true }, caps: { type: "object", additionalProperties: { type: "integer" }, example: { "uo.creatures": 30 } } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The stored setting', content: { "application/json": { schema: { type: "object", properties: { action: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'enabled is missing, or a cap names a dimension this action does not spend', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No module registers that action', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.saveAction,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/series',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List the event series a definition may belong to'
|
||||
// #swagger.description = 'A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.listSeries,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/series',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Create an event series'
|
||||
// #swagger.description = 'Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
|
||||
/* #swagger.responses[201] = { description: 'The created series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.createSeries,
|
||||
)
|
||||
|
||||
eventsRouter.put(
|
||||
'/series/:seriesId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Rename or reorder an event series'
|
||||
// #swagger.description = 'The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The updated series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.updateSeries,
|
||||
)
|
||||
|
||||
eventsRouter.delete(
|
||||
'/series/:seriesId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Delete an event series, detaching whatever belonged to it'
|
||||
// #swagger.description = 'A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Deleted; detached is how many definitions lost their series', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, detached: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.deleteSeries,
|
||||
)
|
||||
|
||||
// ── The calendar ────────────────────────────────────────────────────
|
||||
|
||||
eventsRouter.get(
|
||||
'/calendar',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'The calendar for a window: materialised runs and projected occurrences'
|
||||
// #swagger.description = 'Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['from'] = { in: 'query', description: 'Window start, a UTC instant', required: true, schema: { type: 'string' } }
|
||||
// #swagger.parameters['to'] = { in: 'query', description: 'Window end, a UTC instant. At most 92 days after from', required: true, schema: { type: 'string' } }
|
||||
// #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status; suppresses projections', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['scope'] = { in: 'query', description: 'Only runs at this scope; suppresses projections', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['seriesId'] = { in: 'query', description: 'Only events belonging to this series', required: false, schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The window', content: { "application/json": { schema: { type: "object", properties: { window: { type: "object", additionalProperties: true }, horizon: { type: "string" }, horizonDays: { type: "integer" }, truncated: { type: "boolean" }, entries: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, runId: { type: "integer", nullable: true }, definitionId: { type: "integer" }, title: { type: "string" }, slug: { type: "string" }, seriesName: { type: "string", nullable: true }, scheduledFor: { type: "string" }, timezone: { type: "string" }, scope: { type: "string" }, status: { type: "string", nullable: true }, health: { type: "string", nullable: true }, adjusted: { type: "string", nullable: true } } } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The window is missing, inverted or wider than 92 days', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.calendar,
|
||||
)
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Declared ahead of /:id so the literal path is never read as a definition id.
|
||||
|
||||
eventsRouter.get(
|
||||
'/runs',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List event runs across every definition, newest occurrence first'
|
||||
// #swagger.description = 'A run is one occurrence of one definition in one scope. Until the runner ships, every row here sits at `scheduled` — that is correct for this phase rather than a stalled run.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['definitionId'] = { in: 'query', description: 'Only runs of this definition', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', description: 'How many rows, 1..500 (default 100)', required: false, schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The runs', content: { "application/json": { schema: { type: "object", properties: { runs: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.listRuns,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/runs/:runId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.getRun,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/runs/:runId/log',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'The diagnostic log for one run'
|
||||
// #swagger.description = 'Structured and queryable, unlike activity_log.detail: this is what answers "why did not phase 3 start?" without reading server logs. The audit of who published what is written separately to the activity log.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['limit'] = { in: 'query', description: 'How many lines, 1..2000 (default 500)', required: false, schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The log, newest first, and the closed set of line kinds', content: { "application/json": { schema: { type: "object", properties: { log: { type: "array", items: { type: "object", additionalProperties: true } }, kinds: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
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` joined them in Phase 5, which is when it started meaning something:
|
||||
// a phase with an advance condition can wait on a boss that never spawns, and
|
||||
// that is the one state "force it anyway" names. `cleanup` from the § API
|
||||
// surface table is still not here — it 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` arrived in Phase 8 and DEFAULTS TO TRUE: what the run created or borrowed is given back by the runner cleanup leg on its next tick, which is why this answers at once rather than after a round trip per resource. Sending `cleanup: false` deliberately leaves the world changes in place — that is admin-only even though the route is admin+moderator, because which of the two you have to be depends on what is in the body — and the run then carries `cleanup_status: incomplete` with every unreverted row listed on its console.'
|
||||
// #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." }, cleanup: { type: "boolean", description: "Default true. False leaves the world changes from this run in place, and is admin-only." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The cancelled run, how many steps were closed out with it, and whether cleanup was asked for', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, cancelledSteps: { type: "integer" }, cleanup: { type: "boolean" } } } } } } */
|
||||
/* #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, or a moderator asking to skip cleanup', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
liveControl,
|
||||
controller.cancelRun,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/runs/:runId/cleanup',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Re-run cleanup over everything this run has not given back'
|
||||
// #swagger.description = 'The manual retry EVENTS.md §L promises, and the only thing that clears a resource attempt counter — the automatic sweep never does, because a sweep that reset every stale row is what made an attempt ceiling unreachable in the engagement workstream. Legal on a TERMINAL run only: a run still in flight has a ledger that is still growing, and reverting a resource the next step is about to use would be core undoing an event while it is happening. `admin` rather than admin+moderator, unlike the seven live controls beside it, because this is not incident response — it asks core to write to the world again, which §K puts in the same row as the world-changing actions themselves. Answers 200 whatever it found: some resources may still be out there, and a 4xx would make that indistinguishable from a bad run id.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The run and what the sweep managed', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, summary: { type: "object", properties: { attempted: { type: "integer" }, reverted: { type: "integer" }, drifted: { type: "integer" }, failed: { type: "integer" }, remaining: { type: "integer" } } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The run is still in flight, or recorded no resources at all', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.cleanupRun,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/runs/:runId/advance',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Force the current phase past its advance condition'
|
||||
// #swagger.description = 'The other half of the diagnosis panel: a screen that says why a phase has not started, beside the control that does something about it. Legal only while the phase is genuinely waiting on its gate, and the three refusals are the design — a run that is not `running` is waiting on nothing; a phase with no advance condition already advances on its steps; and a phase with a step still open is held by that step, not by its gate, so the step-level skip is the honest control. Satisfies the gate and stops: the next tick performs the phase transition, exactly as it does after resume, so there is only ever one implementation of what a phase boundary is. The log records `because: forced` with the actor, the reason and how long the phase had waited.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Why the condition was overridden. Recorded in the run log with the actor." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The run, and the phase that was released', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, phase: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The phase is not waiting on an advance condition', 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.advanceRunPhase,
|
||||
)
|
||||
|
||||
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(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List every event definition with its state and current version'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['state'] = { in: 'query', description: 'Only definitions in this state: draft, ready or archived', required: false, schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The definitions', content: { "application/json": { schema: { type: "object", properties: { events: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.list,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Create a draft event definition'
|
||||
// #swagger.description = 'Creates a draft. The slug is derived from the title once and frozen afterwards, because the public event page lives at it. The spec defaults to one empty phase; steps are validated against the action catalog, and an unknown action id is refused.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } }, required: ["title"] } } } } */
|
||||
/* #swagger.responses[201] = { description: 'The created draft', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.create,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One event definition, including its working spec'
|
||||
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.get,
|
||||
)
|
||||
|
||||
eventsRouter.put(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Edit a definition and its working spec'
|
||||
// #swagger.description = 'Editing is free and never touches a published version: a live run keeps the version it pinned. A step naming an action whose module has since been uninstalled is KEPT and marked dormant rather than refused, so an uninstall is never destructive after the fact — but a dormant step blocks publish. An archived definition cannot be edited.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The saved definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
adminOrEditor,
|
||||
controller.update,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/:id/versions',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'The version history of one definition'
|
||||
// #swagger.description = 'Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The versions, newest first', content: { "application/json": { schema: { type: "object", properties: { versions: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.listVersions,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/:id/verify',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps'
|
||||
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.verify,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/:id/publish',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.publish,
|
||||
)
|
||||
|
||||
eventsRouter.delete(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Archive a definition — never a hard delete'
|
||||
// #swagger.description = 'Archiving keeps the definition history without it ever running again. Refused while a run of it is still in flight: cancel the run first. A hard delete is not offered at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The archived definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A run of this definition is still in flight', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.archive,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/:id/runs',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Create an occurrence of a published definition'
|
||||
// #swagger.description = 'Admin only, on the same reasoning as publish: starting commits the deployment to a run. Materialised with INSERT IGNORE against UNIQUE (definition_id, scope, scheduled_for), so asking twice for one occurrence answers with the existing row and `created: false` rather than creating a second. Until the runner ships the row stays `scheduled` and nothing dispatches — its steps and their idempotency keys are inspectable in the meantime.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { scope: { type: "string", description: "Module-opaque. Core stores it verbatim and never parses it." }, scheduledFor: { type: "string", description: "UTC instant; defaults to now" }, rehearsal: { type: "boolean" }, params: { type: "object", additionalProperties: true, description: "Rendered into the definition concurrency_key template" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'The occurrence was created', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The occurrence already existed and is returned unchanged', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is not ready, or has no published version', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.startRun,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
@@ -31,6 +31,7 @@ const discordBotRouter = require('./discordBot.router')
|
||||
const settingsRouter = require('./settings.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const engagementRouter = require('./engagement.router')
|
||||
const eventsRouter = require('./events.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamsVoiceRouter = require('./teamsVoice.router')
|
||||
const dashboardRouter = require('./dashboard.router')
|
||||
@@ -87,6 +88,13 @@ adminRouter.use('/modules', modulesRouter)
|
||||
// /modules above and for a related reason: this is the surface that decides who
|
||||
// the site sends mail to.
|
||||
adminRouter.use('/engagement', engagementRouter)
|
||||
// The Event System (EVENTS.md § API surface, Phase 1). Staff-wide for the reads
|
||||
// and gated per route for the writes, which is where §N2's asymmetry lives:
|
||||
// publish and start are `admin` ONLY, while the live run controls Phase 3 adds
|
||||
// are `admin` + `moderator`. Starting commits the deployment to an unattended
|
||||
// world change; cancelling is incident response, and they are deliberately not
|
||||
// the same gate.
|
||||
adminRouter.use('/events', eventsRouter)
|
||||
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
|
||||
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
|
||||
// per request inside the controller, not per route — a moderator may call them,
|
||||
|
||||
29
server/src/router/v1/player/events.controller.js
Normal file
29
server/src/router/v1/player/events.controller.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Player · Events — the one handler behind /player/events/history (Phase 14a).
|
||||
//
|
||||
// Self-scoped on `req.user.id` and on nothing the caller sent. The model does
|
||||
// the same joins the public surface does — rehearsals and unlisted events are
|
||||
// absent — so a participant cannot learn from their own history that an
|
||||
// unannounced event exists.
|
||||
|
||||
const events = require('../../../model/events/eventPublic.model')
|
||||
const log = require('../../../utils/logger')('player:events')
|
||||
|
||||
async function getHistory(req, res) {
|
||||
try {
|
||||
// A non-integer cursor is dropped rather than bound. `Number('abc')` is NaN,
|
||||
// and NaN reaching a placeholder is a driver-level failure — a 500 for what
|
||||
// is a malformed query string, and the honest answer to one is the first
|
||||
// page.
|
||||
const cursor = Number(req.query.before)
|
||||
const result = await events.history(req.user.id, {
|
||||
limit: req.query.limit ? Number(req.query.limit) : undefined,
|
||||
before: Number.isInteger(cursor) && cursor > 0 ? cursor : null,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('participation history failed', { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getHistory }
|
||||
39
server/src/router/v1/player/events.router.js
Normal file
39
server/src/router/v1/player/events.router.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Player · Events — this account's participation history (EVENTS.md § API
|
||||
// surface, Phase 14a). Mounted at /api/v1/player/events by player/index.js.
|
||||
//
|
||||
// The group gate is `requireAuth` and it is the whole gate: this is role-agnostic
|
||||
// self-service, like the rest of /player. Staff are a superset of players (see
|
||||
// player/index.js), and an admin reading their own attendance is exactly as
|
||||
// ordinary as a player doing it.
|
||||
//
|
||||
// **No backtick in a `#swagger.parameters` annotation.** Unlike `#swagger.summary`
|
||||
// and `#swagger.description`, which are plain strings, a parameters annotation is
|
||||
// parsed as an object literal — a backtick inside its quoted `description` is
|
||||
// rewritten as a quote, and swagger-autogen then DROPS the whole annotation with a
|
||||
// syntax error rather than failing the build.
|
||||
//
|
||||
// **There is no id parameter, deliberately.** The history is `req.user.id`'s and
|
||||
// nothing else's; a route that took a user id would be one middleware mistake
|
||||
// away from publishing who attended what, which is a question about people
|
||||
// rather than about events.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
eventsRouter.get(
|
||||
'/history',
|
||||
// #swagger.tags = ['Player · Events']
|
||||
// #swagger.summary = 'This account’s event participation'
|
||||
// #swagger.description = 'The events this account took part in, most recent first — the run, when it was, the score a module reported, and the rank once results were published. `rank` is null until then, which is a real state rather than an error. Rehearsals and unlisted events are absent, the same rule the public calendar follows. Keyset paging: pass the last entry’s `id` as `before`.'
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
|
||||
// #swagger.parameters['before'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Cursor: the id of the last entry on the previous page.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Participation history', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerEventHistory" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not signed in', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getHistory,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
|
||||
const appealsRouter = require('./appeals.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamForumRouter = require('./teamForum.router')
|
||||
const eventsRouter = require('./events.router')
|
||||
|
||||
const playerRouter = express.Router()
|
||||
|
||||
@@ -39,6 +40,9 @@ const playerRouter = express.Router()
|
||||
playerRouter.use(noindex, requireAuth)
|
||||
|
||||
playerRouter.use('/appeals', appealsRouter)
|
||||
// This account's own event participation (Phase 14a). Self-scoped on
|
||||
// req.user.id, like everything else in this group.
|
||||
playerRouter.use('/events', eventsRouter)
|
||||
playerRouter.use('/teams', teamsRouter)
|
||||
// Same prefix, second router. The forum and the leader-exercised grant flow are a
|
||||
// different capability from "the caller's own Teams", and splitting them keeps
|
||||
|
||||
68
server/src/router/v1/public/events.controller.js
Normal file
68
server/src/router/v1/public/events.controller.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Public · Events — the anonymous event surface (EVENTS.md § API surface).
|
||||
//
|
||||
// Phase 14a. Three reads and no writes: the calendar, one event, one arc.
|
||||
//
|
||||
// **Every one of them is a thin pass-through to `eventPublic.model`, and that is
|
||||
// deliberate.** The projection — which fields exist at all on a public entry — is
|
||||
// the security boundary, and it belongs in one file rather than in three
|
||||
// controllers that would each have to remember it. What is left here is the
|
||||
// HTTP: parse the query, map the model's `status` onto a response code, and turn
|
||||
// a thrown read into a 500 rather than a stack trace.
|
||||
//
|
||||
// **A 404 here means "no such public event"** and cannot be told from "no such
|
||||
// slug at all". A draft, an archived definition and an unlisted one answer
|
||||
// identically, which is the whole point: an operator who has not announced
|
||||
// something has not announced its existence either.
|
||||
|
||||
const events = require('../../../model/events/eventPublic.model')
|
||||
const log = require('../../../utils/logger')('public:events')
|
||||
|
||||
const fail = (res, err, what) => {
|
||||
log.error(`${what} failed`, { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
const answer = (res, result) =>
|
||||
result.ok
|
||||
? res.json(result)
|
||||
: res.status(result.status || 400).json({ message: result.errors?.[0] || 'Bad Request', errors: result.errors })
|
||||
|
||||
async function getCalendar(req, res) {
|
||||
try {
|
||||
const seriesId = req.query.seriesId ? Number(req.query.seriesId) : null
|
||||
if (req.query.seriesId && !Number.isInteger(seriesId)) {
|
||||
return res.status(400).json({ message: 'seriesId must be an integer' })
|
||||
}
|
||||
const result = await events.calendar({
|
||||
from: req.query.from || null,
|
||||
to: req.query.to || null,
|
||||
seriesId,
|
||||
})
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public calendar')
|
||||
}
|
||||
}
|
||||
|
||||
async function getEvent(req, res) {
|
||||
try {
|
||||
// `run` is optional and un-validated beyond being carried through as a
|
||||
// string: the model matches it against this definition's own runs and
|
||||
// ignores anything else, so a garbage value renders the page rather than an
|
||||
// error. See the model's note on why it is not refused.
|
||||
const result = await events.event(req.params.slug, { runId: req.query.run || null })
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public event')
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeries(req, res) {
|
||||
try {
|
||||
return answer(res, await events.series(req.params.slug))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public series')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getCalendar, getEvent, getSeries }
|
||||
60
server/src/router/v1/public/events.router.js
Normal file
60
server/src/router/v1/public/events.router.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Public · Events — mounted at /api/v1/public/events by public/index.js.
|
||||
//
|
||||
// No group gate: this is the anonymous surface, and `siteMode` is applied per
|
||||
// route as it is everywhere else in this tier — during maintenance only an admin
|
||||
// with a valid session sees content.
|
||||
//
|
||||
// Declaration order: '/' is literal and precedes ':slug', and 'series/:slug' is
|
||||
// declared BEFORE ':slug' although it could not be shadowed by it (two segments
|
||||
// against one). It stays above so the relationship is visible to whoever adds
|
||||
// the next route here — and because the one route bug this feature has already
|
||||
// shipped was exactly a static/dynamic ranking surprise, one tier up in React
|
||||
// Router (see App.jsx's note above `events/:id`).
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
eventsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'The public event calendar'
|
||||
// #swagger.description = 'Upcoming, live and recent events in a window, ascending by instant. An entry is one of two things and says which: a `run` is a materialised occurrence, and a `projected` entry is arithmetic past the materialisation horizon — a forecast, with nothing committed to it, which a client should draw as such. Instants are UTC and each entry carries the EVENT\'s own timezone, because a shard-local 8pm means the shard\'s evening to everyone reading it; the reader\'s own zone places the entry in a month grid. Rehearsals and unlisted events are absent. Defaults to now through 31 days out; the window may span at most 92 days.'
|
||||
// #swagger.parameters['from'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window start (ISO). Defaults to now.' }
|
||||
// #swagger.parameters['to'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window end (ISO). Defaults to 31 days after the start.' }
|
||||
// #swagger.parameters['seriesId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Restrict to one arc.' }
|
||||
/* #swagger.responses[200] = { description: 'The calendar', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventCalendar" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Bad window', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getCalendar,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/series/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One arc'
|
||||
// #swagger.description = 'A series and the listed events in it, in the order an editor arranged them. A series with no listed events answers 404 rather than an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The series slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The arc', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventSeries" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such arc, or nothing in it is listed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getSeries,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One event'
|
||||
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
|
||||
// #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
|
||||
/* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getEvent,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const eventsRouter = require('./events.router')
|
||||
const engagementRouter = require('./engagement.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
@@ -42,6 +43,10 @@ publicRouter.use('/modules', modulesRouter)
|
||||
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
|
||||
// content above it.
|
||||
publicRouter.use('/teams', teamsRouter)
|
||||
// Events. A core prefix like /teams: the calendar, the event page and the arc
|
||||
// are core's surface even when every step an event dispatches belongs to a
|
||||
// module. Site-mode gated per route, like the content above it.
|
||||
publicRouter.use('/events', eventsRouter)
|
||||
// The unauthenticated half of the engagement system: today exactly the
|
||||
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
|
||||
// token names is a channel and a scope and a scope is not always a Team
|
||||
|
||||
@@ -11,9 +11,12 @@ const botScore = require('./middleware/botScore')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const teamActivityPrune = require('./utils/teamActivityPrune')
|
||||
const inboxPrune = require('./utils/userNotificationsPrune')
|
||||
const engagementRetentionPrune = require('./utils/engagementRetentionPrune')
|
||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||
const teamDigestWorker = require('./utils/teamDigestWorker')
|
||||
const engagementWorker = require('./utils/engagementWorker')
|
||||
const eventRunner = require('./utils/eventRunner')
|
||||
const eventCleanup = require('./events/cleanup')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
@@ -160,6 +163,7 @@ async function start() {
|
||||
// rather than after someone notices. No-op on a deployment with no Teams.
|
||||
teamActivityPrune.start()
|
||||
inboxPrune.start()
|
||||
engagementRetentionPrune.start()
|
||||
teamForumUploadSweep.start()
|
||||
teamDigestWorker.start()
|
||||
|
||||
@@ -167,6 +171,25 @@ async function start() {
|
||||
// enables a rule: core seeds none and `enabled` defaults to 0.
|
||||
engagementWorker.start()
|
||||
|
||||
// Advance scheduled events (EVENTS.md §E). Materialise, advance, drain, clean
|
||||
// up and the run-log retention sweep. No-op until an admin publishes a
|
||||
// definition and starts a run: core ships no event definitions.
|
||||
eventRunner.start()
|
||||
|
||||
// **Core's own restart is the one reconnect core can see** (EVENTS.md §L,
|
||||
// Phase 8). Every other one belongs to a module, which reports it through
|
||||
// `ctx.events.reconcile()`; this is the case where the thing that restarted was
|
||||
// this process, and the ledger it wakes up holding may describe a world that
|
||||
// moved on while it was down. Awaited by nobody and never fatal: a module that
|
||||
// cannot answer leaves its rows alone, which is the pre-Phase-8 behaviour.
|
||||
eventCleanup
|
||||
.reconcileAll()
|
||||
.then((summaries) => {
|
||||
const orphaned = Object.values(summaries).reduce((n, x) => n + (x.orphaned || 0), 0)
|
||||
if (orphaned) log.warn('event resources orphaned at boot', { orphaned, summaries })
|
||||
})
|
||||
.catch((err) => log.error('boot reconcile failed', { message: err.message }))
|
||||
|
||||
setupShutdown(server, internalServer)
|
||||
}
|
||||
|
||||
@@ -186,9 +209,11 @@ function setupShutdown(server, internalServer) {
|
||||
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||
teamActivityPrune.stop() // stop the Team activity retention timer
|
||||
inboxPrune.stop() // stop the in-app inbox retention timer
|
||||
engagementRetentionPrune.stop() // stop the engagement retention timer
|
||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||
teamDigestWorker.stop() // stop the Team forum digest timer
|
||||
engagementWorker.stop() // stop the engagement outbox worker
|
||||
eventRunner.stop() // stop the event runner
|
||||
server.close(() => log.info('http server closed'))
|
||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||
try {
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
// silently loses a variable is a template that silently renders `undefined`.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
const engine = require('../engagement/engine')
|
||||
const eventGates = require('../events/gates')
|
||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||
const createLogger = require('./logger')
|
||||
|
||||
@@ -165,7 +167,7 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
|
||||
}
|
||||
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds } = envelope || {}
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds, ceiling } = envelope || {}
|
||||
|
||||
const payload = validatePayload(declaration, data)
|
||||
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
|
||||
@@ -225,6 +227,33 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
resolvedRecipients = [...new Set(recipientUserIds)]
|
||||
}
|
||||
|
||||
// **A ceiling this ONE firing may not exceed** (MODULE_API 1.11.0, EVENTS.md
|
||||
// §I, org lead 2026-09-04). A trigger's declared ceiling is a property of the
|
||||
// KIND of event; this is a property of the occasion, and the two are different
|
||||
// questions. The case that forced it is the rehearsal: §I promises an event
|
||||
// can be "run for real with announcements ceilinged to `staff`", and a
|
||||
// rehearsal fires exactly the same trigger as the real thing — so without a
|
||||
// per-firing bound, rehearsing a published event mails every subscriber it.
|
||||
//
|
||||
// **It only ever NARROWS.** The send-time G24 gate takes
|
||||
// `meet(declared, emitted)`, so an emitter can tighten a ceiling and can never
|
||||
// loosen one, and an emitter that names something incomparable with the
|
||||
// declaration — `owner` against a declared `staff` — meets to null and the gate
|
||||
// refuses every rule rather than guessing which branch was meant. That is the
|
||||
// lattice's existing posture (`segments.js` §5.1a rule 3), reused rather than
|
||||
// re-argued.
|
||||
//
|
||||
// Not stored on the outbox row, deliberately: by the time a row exists the gate
|
||||
// has already run, and a second copy of the bound would be a second thing that
|
||||
// can disagree with the declaration it was checked against.
|
||||
let resolvedCeiling = null
|
||||
if (ceiling !== undefined && ceiling !== null) {
|
||||
if (!ceilings.isCeiling(ceiling)) {
|
||||
return fail(`ceiling must be one of ${ceilings.CEILINGS.join(', ')}`)
|
||||
}
|
||||
resolvedCeiling = ceiling
|
||||
}
|
||||
|
||||
if (dedupeKey !== undefined && dedupeKey !== null) {
|
||||
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
|
||||
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
|
||||
@@ -246,6 +275,7 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
|
||||
scopeKey: resolvedScope,
|
||||
recipientUserIds: resolvedRecipients,
|
||||
ceiling: resolvedCeiling,
|
||||
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
|
||||
occurredAt: at.toISOString(),
|
||||
data: payload.data,
|
||||
@@ -276,6 +306,20 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
// await the delivery decision, and the tests use it directly.
|
||||
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
// **The trigger stream's second subscriber** (EVENTS.md §E, Phase 5). An event
|
||||
// run whose phase is waiting on `{ on: '<triggerId>', count: n }` counts this
|
||||
// firing here, at the moment it fires, because nothing observable survives to
|
||||
// the runner's next tick. Same seam, same posture: not awaited, never allowed
|
||||
// to reject, and it knows nothing about who emitted.
|
||||
//
|
||||
// It is a SECOND subscriber and not a leg of `dispatch` because the two
|
||||
// decide different things — who gets told, and whether a phase may proceed —
|
||||
// and neither must be able to fail the other. A rules lookup that throws must
|
||||
// not lose the count, and a gate write that throws must not lose the mail.
|
||||
eventGates
|
||||
.observe(event)
|
||||
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
|
||||
|
||||
return { ok: true, event }
|
||||
}
|
||||
|
||||
|
||||
134
server/src/utils/engagementRetentionPrune.js
Normal file
134
server/src/utils/engagementRetentionPrune.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── Engagement retention worker ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. Three of the four engagement tables grow on every fire
|
||||
// and nothing has ever deleted from any of them: `engagement_cooldowns` (one row
|
||||
// per rule × user × subject × channel per fire), `engagement_outbox` (one row per
|
||||
// enqueued delivery, terminal rows included) and `engagement_sends` (one row per
|
||||
// attempt). `engagement_suppressions` is the fourth and does not expire — see
|
||||
// `engagementRetention.model.js` for why that is a decision rather than an
|
||||
// omission.
|
||||
//
|
||||
// **One worker, three sweeps, not three workers.** They share a timer, a batch
|
||||
// discipline and one settings-backed policy object; splitting them would give an
|
||||
// operator three independent nightly table-wide DELETEs to reason about and
|
||||
// three places for a horizon to be read differently.
|
||||
//
|
||||
// Same in-process shape as `teamActivityPrune` and `userNotificationsPrune` —
|
||||
// setInterval + unref + stop(), wired into server.js start/shutdown beside them,
|
||||
// with the first run delayed so a table-wide DELETE never lands in front of the
|
||||
// first request on a crash-looping deployment.
|
||||
|
||||
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
|
||||
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const retention = require('../model/engagement/engagementRetention.model')
|
||||
const log = require('./logger')('engagement')
|
||||
|
||||
const INTERVAL_MS = Number(process.env.ENGAGEMENT_PRUNE_MS) || 24 * 60 * 60 * 1000
|
||||
const FIRST_RUN_MS = Number(process.env.ENGAGEMENT_PRUNE_DELAY_MS) || 10 * 60 * 1000
|
||||
|
||||
// A bound per statement, so one run after a long outage is a series of bounded
|
||||
// DELETEs rather than one holding locks over a million rows. Each sweep repeats
|
||||
// until it clears and stops early rather than looping forever; a run that hits
|
||||
// the ceiling simply resumes tomorrow, which is what a horizon means anyway.
|
||||
const BATCH = 1000
|
||||
const MAX_BATCHES = 50
|
||||
|
||||
const daysAgo = (days, now) => new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
|
||||
/** Repeat a bounded delete until it stops filling its batch. Never throws. */
|
||||
async function sweep(name, del) {
|
||||
let removed = 0
|
||||
for (let i = 0; i < MAX_BATCHES; i += 1) {
|
||||
const n = await del()
|
||||
removed += n
|
||||
if (n < BATCH) break
|
||||
}
|
||||
if (removed) log.info('engagement retention swept', { table: name, removed })
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass over all three tables.
|
||||
*
|
||||
* Each sweep is caught on its own: a failure in one (a lock timeout on a huge
|
||||
* outbox, say) must not stop the other two from being bounded. Never throws — it
|
||||
* runs on a timer with nobody to catch it.
|
||||
*/
|
||||
async function tick(now = new Date()) {
|
||||
const result = { cooldowns: 0, outbox: 0, sends: 0, warnings: [] }
|
||||
let policy
|
||||
try {
|
||||
policy = await retention.get()
|
||||
} catch (err) {
|
||||
log.error('engagement retention policy unreadable; skipping this run', { message: err.message })
|
||||
return result
|
||||
}
|
||||
|
||||
// Phase 14's acceptance line: checked, not picked. A horizon shorter than a
|
||||
// live cooldown is logged and swept anyway — see the model for that trade.
|
||||
try {
|
||||
const check = await retention.checkCooldownHorizon(policy.cooldowns)
|
||||
if (!check.ok) {
|
||||
result.warnings.push(check.message)
|
||||
log.warn('cooldown retention is shorter than a live cooldown', {
|
||||
retainDays: policy.cooldowns,
|
||||
longestCooldownSeconds: check.longestCooldownSeconds,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('cooldown horizon check failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.cooldowns = await sweep('engagement_cooldowns', () =>
|
||||
cooldownsDb.prune(daysAgo(policy.cooldowns, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('cooldown prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.outbox = await sweep('engagement_outbox', () =>
|
||||
outboxDb.pruneTerminal(daysAgo(policy.outbox, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('outbox prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
result.sends = await sweep('engagement_sends', () =>
|
||||
sendsDb.prune(daysAgo(policy.sends, now), BATCH))
|
||||
} catch (err) {
|
||||
log.error('send-log prune failed', { message: err.message })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let timer = null
|
||||
let firstRun = null
|
||||
|
||||
function start() {
|
||||
if (timer || firstRun) return timer
|
||||
firstRun = setTimeout(() => {
|
||||
firstRun = null
|
||||
tick()
|
||||
timer = setInterval(() => { tick() }, INTERVAL_MS)
|
||||
if (timer.unref) timer.unref()
|
||||
}, FIRST_RUN_MS)
|
||||
if (firstRun.unref) firstRun.unref()
|
||||
log.info('engagement retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
|
||||
return timer
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (firstRun) {
|
||||
clearTimeout(firstRun)
|
||||
firstRun = null
|
||||
}
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, BATCH, MAX_BATCHES, INTERVAL_MS, FIRST_RUN_MS }
|
||||
@@ -153,7 +153,7 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
|
||||
async function tick(now = new Date()) {
|
||||
try {
|
||||
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS))
|
||||
await outboxDb.reclaimStale(new Date(now.getTime() - STALE_MS), MAX_ATTEMPTS)
|
||||
} catch (err) {
|
||||
log.error('failed to reclaim stale rows', { message: err.message })
|
||||
}
|
||||
|
||||
1014
server/src/utils/eventRunner.js
Normal file
1014
server/src/utils/eventRunner.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user