Merge pull request 'feat(events): the Event System — core (Phase 16b cutover, 2 of 6)' (#199) from edge into main
Reviewed-on: #199 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
|
|||||||
import Newsletter from './routes/public/Newsletter.jsx'
|
import Newsletter from './routes/public/Newsletter.jsx'
|
||||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||||
import About from './routes/public/About.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 Status from './routes/public/Status.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
@@ -49,6 +52,11 @@ import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
|||||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||||
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
||||||
import EngagementRetention from './routes/admin/views/EngagementRetention.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 TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
@@ -69,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
|||||||
import PlayerInbox from './routes/player/PlayerInbox.jsx'
|
import PlayerInbox from './routes/player/PlayerInbox.jsx'
|
||||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||||
|
import PlayerEvents from './routes/player/PlayerEvents.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -102,6 +111,17 @@ export default function App() {
|
|||||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
<Route path="/site/about" element={<About />} />
|
<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="/site/status" element={<Status />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
@@ -192,6 +212,27 @@ export default function App() {
|
|||||||
actions that publish a game-written name is applied per request
|
actions that publish a game-written name is applied per request
|
||||||
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
||||||
<Route path="teams" element={<TeamsAdmin />} />
|
<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
|
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
|
||||||
server: every route under /admin/engagement re-gates to `admin`
|
server: every route under /admin/engagement re-gates to `admin`
|
||||||
on top of the group's staff gate, because this is the group that
|
on top of the group's staff gate, because this is the group that
|
||||||
@@ -221,6 +262,15 @@ export default function App() {
|
|||||||
two paths; `lib/notificationPaths.js` is the one mapping. */}
|
two paths; `lib/notificationPaths.js` is the one mapping. */}
|
||||||
<Route path="notifications" element={<PlayerInbox />} />
|
<Route path="notifications" element={<PlayerInbox />} />
|
||||||
<Route path="notifications/settings" element={<PlayerNotifications />} />
|
<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
|
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||||
wrapper — only an optional { roles }, which core applies as the
|
wrapper — only an optional { roles }, which core applies as the
|
||||||
@@ -264,6 +314,11 @@ export default function App() {
|
|||||||
<Route path="/player" element={<PlayerIndex />} />
|
<Route path="/player" element={<PlayerIndex />} />
|
||||||
<Route path="/account" element={<PlayerAccount />} />
|
<Route path="/account" element={<PlayerAccount />} />
|
||||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
<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
|
{/* The inbox took `/account/notifications` in engagement Phase 7
|
||||||
and the preferences screen moved under it. Content and
|
and the preferences screen moved under it. Content and
|
||||||
settings are different kinds of thing, and the plain word
|
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.
|
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||||
unsubscribeTeam: (token) =>
|
unsubscribeTeam: (token) =>
|
||||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
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'),
|
wikiTags: () => req('/public/wiki/tags'),
|
||||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||||
@@ -472,6 +490,100 @@ export const api = {
|
|||||||
setEngagementRetention: (body) =>
|
setEngagementRetention: (body) =>
|
||||||
req('/admin/engagement/retention', { method: 'PUT', 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
|
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||||
// different depending on who calls them: for a moderator, unhide and
|
// different depending on who calls them: for a moderator, unhide and
|
||||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||||
@@ -614,6 +726,18 @@ export const api = {
|
|||||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||||
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
|
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
|
||||||
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
|
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 = [
|
export const NAV = [
|
||||||
{ label: 'Home', to: '/', end: true },
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
|
{ label: 'Events', to: '/site/events' },
|
||||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ 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. */
|
/** The per-channel preferences screen. */
|
||||||
export const notificationSettingsPath = (user) =>
|
export const notificationSettingsPath = (user) =>
|
||||||
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
|
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,15 @@
|
|||||||
// that the two files can drift, so a test asserts they agree
|
// that the two files can drift, so a test asserts they agree
|
||||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||||
// both.
|
// 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:
|
// 1.9.0 - a module may ship its own message bodies and rules:
|
||||||
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
|
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
|
||||||
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
|
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
|
||||||
@@ -65,4 +74,4 @@
|
|||||||
// but the two halves state ONE version: a module declares a single coreApi range
|
// 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
|
// 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.
|
// answered 1.1.0 would be two answers to one question.
|
||||||
export const MODULE_API_VERSION = '1.9.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 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 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 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
|
// 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`
|
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||||
@@ -120,6 +121,35 @@ export const NAV = [
|
|||||||
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
|
{ 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'System',
|
title: 'System',
|
||||||
items: [
|
items: [
|
||||||
@@ -202,6 +232,11 @@ const TITLES = {
|
|||||||
'/admin/engagement/suppressions': 'Suppressions',
|
'/admin/engagement/suppressions': 'Suppressions',
|
||||||
'/admin/engagement/sends': 'Send Log',
|
'/admin/engagement/sends': 'Send Log',
|
||||||
'/admin/engagement/retention': 'Retention',
|
'/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
|
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||||
@@ -222,6 +257,10 @@ function sectionTitle(pathname) {
|
|||||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||||
if (pathname.startsWith('/admin/users/')) return 'User'
|
if (pathname.startsWith('/admin/users/')) return 'User'
|
||||||
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
|
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'
|
return 'Admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
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
|
// 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.
|
// 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>
|
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;
|
// 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,
|
// UO module registers it again at `/player/uo/characters`, in this position,
|
||||||
// with `order: 0`.
|
// with `order: 0`.
|
||||||
export const NAV = [
|
export const NAV = [
|
||||||
|
{ to: '/account/events', label: 'Events', icon: IconCalendar },
|
||||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||||
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
|
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
|
||||||
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
|
{ 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' })
|
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')
|
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')
|
||||||
|
})
|
||||||
@@ -2083,3 +2083,616 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
|||||||
INDEX idx_engsup_created (created_at),
|
INDEX idx_engsup_created (created_at),
|
||||||
INDEX idx_engsup_reason (reason, created_at)
|
INDEX idx_engsup_reason (reason, created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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.",
|
"_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.9.0",
|
"moduleApiVersion": "1.10.0",
|
||||||
"triggers": [
|
"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",
|
"id": "news.post",
|
||||||
"owner": "core",
|
"owner": "core",
|
||||||
|
|||||||
@@ -419,6 +419,276 @@
|
|||||||
"requireAuth"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/invites",
|
"path": "/api/v1/admin/invites",
|
||||||
@@ -1997,6 +2267,15 @@
|
|||||||
"requireAuth"
|
"requireAuth"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/events/history",
|
||||||
|
"handlers": 1,
|
||||||
|
"gates": [
|
||||||
|
"noindex",
|
||||||
|
"requireAuth"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/teams",
|
"path": "/api/v1/player/teams",
|
||||||
@@ -2174,6 +2453,30 @@
|
|||||||
"handlers": 1,
|
"handlers": 1,
|
||||||
"gates": []
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/modules",
|
"path": "/api/v1/public/modules",
|
||||||
|
|||||||
@@ -185,6 +185,126 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/engagement/triggers"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/invites"
|
"path": "/api/v1/admin/invites"
|
||||||
@@ -813,6 +933,10 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/appeals/eligible"
|
"path": "/api/v1/player/appeals/eligible"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/events/history"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/teams"
|
"path": "/api/v1/player/teams"
|
||||||
@@ -885,6 +1009,18 @@
|
|||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/public/engagement/unsubscribe/:token"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/modules"
|
"path": "/api/v1/public/modules"
|
||||||
|
|||||||
@@ -108,7 +108,21 @@ function main() {
|
|||||||
return
|
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) {
|
if (current === next) {
|
||||||
process.stdout.write('engagement-triggers.json is current\n')
|
process.stdout.write('engagement-triggers.json is current\n')
|
||||||
return
|
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,
|
personal: false,
|
||||||
requiresLinkedAccount: 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 }
|
module.exports = { STREAMS }
|
||||||
|
|||||||
@@ -29,6 +29,29 @@
|
|||||||
// test-send without a live game event, which is the reason template systems go
|
// test-send without a live game event, which is the reason template systems go
|
||||||
// untested.
|
// 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 = [
|
const TRIGGERS = [
|
||||||
{
|
{
|
||||||
id: 'news.post',
|
id: 'news.post',
|
||||||
@@ -149,6 +172,269 @@ const TRIGGERS = [
|
|||||||
description: 'Site-relative path to the announcement.' },
|
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 }
|
module.exports = { TRIGGERS }
|
||||||
|
|||||||
@@ -11,6 +11,25 @@
|
|||||||
//
|
//
|
||||||
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
|
// `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.
|
// 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')
|
const pkg = require('../../package.json')
|
||||||
|
|
||||||
@@ -18,4 +37,6 @@ module.exports = {
|
|||||||
service: 'runic-gateway', // stable backend identifier for first-run detection
|
service: 'runic-gateway', // stable backend identifier for first-run detection
|
||||||
api: 'v1', // API contract version (matches the /api/v1 mount)
|
api: 'v1', // API contract version (matches the /api/v1 mount)
|
||||||
server: pkg.version || '0.0.0', // server package version (informational)
|
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
|
* 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
|
* otherwise mail a population the current declaration forbids, which is what
|
||||||
* makes this the security boundary rather than a duplicate check.
|
* 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)
|
const declaration = registries.eventTrigger(triggerId)
|
||||||
if (!declaration) return false
|
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 }
|
module.exports = { resolveForRule, permitted, defaultOnChannels }
|
||||||
|
|||||||
@@ -248,4 +248,18 @@ const vocabulary = () =>
|
|||||||
/** Convenience for a caller holding only a trigger id. */
|
/** Convenience for a caller holding only a trigger id. */
|
||||||
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
|
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
|
// 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
|
// 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;
|
// 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
|
// 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.
|
// 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 rulesDb = require('../model/engagement/engagementRules.db')
|
||||||
const settingsDb = require('../model/settings/settings.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.
|
// Phase 11's, and separate for the reason above. Same shape, same semantics.
|
||||||
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
|
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 = [
|
const RULES = [
|
||||||
{
|
{
|
||||||
trigger_id: 'team.forum.post',
|
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.
|
* Seed one group of rules, once, under its own guard key.
|
||||||
*
|
*
|
||||||
@@ -185,7 +260,7 @@ async function seedGroup(key, rules, note) {
|
|||||||
})
|
})
|
||||||
summary.inserted += 1
|
summary.inserted += 1
|
||||||
} catch (err) {
|
} 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 — the claim above is the stamp. Re-running
|
// Stamped even on a partial run — the claim above is the stamp. Re-running
|
||||||
@@ -209,6 +284,10 @@ const seedTeamRules = () =>
|
|||||||
const seedNewsRule = () =>
|
const seedNewsRule = () =>
|
||||||
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
|
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.
|
* Both groups, which is what the boot path calls.
|
||||||
*
|
*
|
||||||
@@ -219,9 +298,10 @@ const seedNewsRule = () =>
|
|||||||
async function seedCoreRules() {
|
async function seedCoreRules() {
|
||||||
const team = await seedTeamRules()
|
const team = await seedTeamRules()
|
||||||
const news = await seedNewsRule()
|
const news = await seedNewsRule()
|
||||||
|
const events = await seedEventRules()
|
||||||
return {
|
return {
|
||||||
inserted: team.inserted + news.inserted,
|
inserted: team.inserted + news.inserted + events.inserted,
|
||||||
skipped: team.skipped + news.skipped,
|
skipped: team.skipped + news.skipped + events.skipped,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +309,11 @@ module.exports = {
|
|||||||
seedCoreRules,
|
seedCoreRules,
|
||||||
seedTeamRules,
|
seedTeamRules,
|
||||||
seedNewsRule,
|
seedNewsRule,
|
||||||
|
seedEventRules,
|
||||||
RULES,
|
RULES,
|
||||||
NEWS_RULES,
|
NEWS_RULES,
|
||||||
|
EVENT_RULES,
|
||||||
SEEDED_KEY,
|
SEEDED_KEY,
|
||||||
NEWS_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
|
// 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
|
// 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.
|
// 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', {
|
log.warn('rule audience exceeds its trigger ceiling - refusing', {
|
||||||
rule: rule.id,
|
rule: rule.id,
|
||||||
trigger: event.triggerId,
|
trigger: event.triggerId,
|
||||||
audience: resolved.ceiling,
|
audience: resolved.ceiling,
|
||||||
|
emitted: event.ceiling || null,
|
||||||
})
|
})
|
||||||
summary.skipped = 'ceiling'
|
summary.skipped = 'ceiling'
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
@@ -310,6 +310,63 @@ const SEEDS = [
|
|||||||
button('cta', 'Open', '{{actionUrl}}'),
|
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`. */
|
/** @returns {object|null} the seed definition for `key`. */
|
||||||
|
|||||||
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 { 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'
|
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
||||||
|
|
||||||
async function legsFor(jobIds) {
|
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 —
|
// 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.
|
// 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)
|
const jobId = Number(res.insertId)
|
||||||
if (legs.length > 0) {
|
if (legs.length > 0) {
|
||||||
const values = legs.map(() => '(?, ?)').join(', ')
|
const values = legs.map(() => '(?, ?)').join(', ')
|
||||||
@@ -65,9 +72,15 @@ async function findById(id) {
|
|||||||
return (await attachLegs(rows))[0]
|
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) {
|
async function findByPostId(postId) {
|
||||||
const rows = await query(
|
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],
|
[postId],
|
||||||
)
|
)
|
||||||
if (rows.length === 0) return null
|
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
|
// 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
|
// 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).
|
// 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))
|
const status = logic.rollupStatus(job.legs.map((l) => l.status))
|
||||||
if (status !== job.status) await db.setStatus(jobId, status)
|
if (status !== job.status) await db.setStatus(jobId, status)
|
||||||
job.status = 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 {
|
try {
|
||||||
await posts.markAnnounced(job.post_id)
|
await posts.markAnnounced(job.post_id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -127,6 +159,7 @@ async function getByPostId(postId) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
enqueue,
|
enqueue,
|
||||||
|
enqueueForRun,
|
||||||
shouldEnqueue,
|
shouldEnqueue,
|
||||||
enqueueIfNeeded,
|
enqueueIfNeeded,
|
||||||
recordOutcome,
|
recordOutcome,
|
||||||
|
|||||||
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 }
|
||||||
440
server/src/model/events/eventPublic.model.js
Normal file
440
server/src/model/events/eventPublic.model.js
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// ── 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. */
|
||||||
|
const publicRunEntry = (run) => ({
|
||||||
|
kind: 'run',
|
||||||
|
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.
|
// All posts for a category (admin), newest first.
|
||||||
async function listAll(category) {
|
async function listAll(category) {
|
||||||
if (category) {
|
if (category) {
|
||||||
@@ -74,6 +87,7 @@ async function countByCategory() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
listPublished,
|
listPublished,
|
||||||
|
listPublishedForOptions,
|
||||||
listAll,
|
listAll,
|
||||||
findById,
|
findById,
|
||||||
findPublished,
|
findPublished,
|
||||||
|
|||||||
@@ -228,6 +228,39 @@ function buildCtx(id, moduleRoot) {
|
|||||||
emit: (triggerId, envelope) => {
|
emit: (triggerId, envelope) => {
|
||||||
engagementEmit.emit(id, 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
|
// 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
|
// rule. Live from Phase 7; it threw until the `user_notifications` table
|
||||||
@@ -370,6 +403,41 @@ function buildApi(record) {
|
|||||||
once('registerEngagementSeeds')
|
once('registerEngagementSeeds')
|
||||||
record.staged.registerEngagementSeeds(seeds)
|
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
|
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
// 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
|
// Both are optional: a module with no warm-up and nothing to close simply
|
||||||
|
|||||||
@@ -124,6 +124,58 @@ const triggers = new Map()
|
|||||||
// trigger of the same name would be a collision between two unrelated things.
|
// trigger of the same name would be a collision between two unrelated things.
|
||||||
const audiences = new Map()
|
const audiences = new Map()
|
||||||
|
|
||||||
|
// action id → { owner, id, label, description, risk, reversible, version,
|
||||||
|
// budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1).
|
||||||
|
//
|
||||||
|
// **Its own id space**, like `audiences` above and for the same kind of reason:
|
||||||
|
// an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as
|
||||||
|
// the thing a module can be asked to do and `uo.champ.start` as the thing that
|
||||||
|
// happened are two unrelated declarations that must not collide with — or
|
||||||
|
// silently satisfy — each other. Nothing cross-checks this map against the
|
||||||
|
// stream/trigger namespace, and nothing should.
|
||||||
|
//
|
||||||
|
// A Map, and read by id on the dispatch path exactly as `triggers` is; insertion
|
||||||
|
// order is what the admin catalog renders in.
|
||||||
|
const eventActions = new Map()
|
||||||
|
|
||||||
|
// budget id → { owner, id, label, unit, description } (EVENTS.md §F, Phase 7).
|
||||||
|
//
|
||||||
|
// A dimension of consumption — "creatures spawned", "gate uptime" — declared so
|
||||||
|
// that the switchboard's cap editor has a NAME and a UNIT to put beside a number.
|
||||||
|
// Phase 6 discovered these by pricing an action's declared `example` values,
|
||||||
|
// which was a stand-in that could name a dimension and never label it.
|
||||||
|
//
|
||||||
|
// **Its own id space**, like `eventActions` above, and §F says why in one line:
|
||||||
|
// an action names a VERB and a budget names a RESOURCE. Nothing cross-checks the
|
||||||
|
// two maps, and nothing should.
|
||||||
|
//
|
||||||
|
// Data only. There is no function on a budget and nothing here is ever called —
|
||||||
|
// the module says a dimension exists and what to call it, `cost()` says how much
|
||||||
|
// of it a step spends, and core owns every piece of arithmetic in between.
|
||||||
|
const eventBudgets = new Map()
|
||||||
|
|
||||||
|
// lease id → { owner, id, label, type, min, max, maxDurationMs, description,
|
||||||
|
// read, apply, restore } (§F "Leases: one more declaration", Phase 7).
|
||||||
|
//
|
||||||
|
// **Core owns the duration and the conflict check; the module owns reading the
|
||||||
|
// current value and writing a new one.** Phase 7 registered a lease and nothing
|
||||||
|
// acquired one; Phase 8 gave it a verb — `core.lease`, a CORE action, so the
|
||||||
|
// bound and the two-events-one-target refusal are enforced in one place rather
|
||||||
|
// than re-implemented by every module that ships a lease.
|
||||||
|
const eventLeases = new Map()
|
||||||
|
|
||||||
|
// source id → { owner, id, label, description, resolve } (§F "Param option
|
||||||
|
// sources", Phase 7).
|
||||||
|
//
|
||||||
|
// What turns an authoring field from a text box into a dropdown of real
|
||||||
|
// landmarks. Modelled on `audiences` rather than on anything else here, because
|
||||||
|
// it is the same shape of thing: an id, a label, and a `resolve()` core calls and
|
||||||
|
// waits for. What differs is the meaning of a refusal — an audience that refuses
|
||||||
|
// mails nobody, while a source that refuses degrades its field to free text with
|
||||||
|
// a warning, because refusing to let an operator type a value they already know
|
||||||
|
// is worse than the typo the dropdown existed to prevent.
|
||||||
|
const eventOptionSources = new Map()
|
||||||
|
|
||||||
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
|
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
|
||||||
// decision 7). What a module ships as CONTENT rather than as contract: the
|
// decision 7). What a module ships as CONTENT rather than as contract: the
|
||||||
// bodies its triggers render through, and the rules an operator switches on.
|
// bodies its triggers render through, and the rules an operator switches on.
|
||||||
@@ -163,6 +215,17 @@ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
|||||||
// Audiences are their own id space (see the `audiences` Map), so they get their
|
// Audiences are their own id space (see the `audiences` Map), so they get their
|
||||||
// own constant even though the grammar is the same one.
|
// own constant even though the grammar is the same one.
|
||||||
const AUDIENCE_ID = EVENT_ID
|
const AUDIENCE_ID = EVENT_ID
|
||||||
|
// Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id
|
||||||
|
// spaces"). One grammar, three namespaces — the constant is what makes the
|
||||||
|
// namespace visible at every use site.
|
||||||
|
const ACTION_ID = EVENT_ID
|
||||||
|
// And three more id spaces on the same grammar, arriving with the module
|
||||||
|
// contract in Phase 7. Three constants rather than three uses of ACTION_ID, for
|
||||||
|
// the reason AUDIENCE_ID gets its own: the constant is what makes the namespace
|
||||||
|
// visible at the use site, so a future divergence has one place to happen.
|
||||||
|
const BUDGET_ID = EVENT_ID
|
||||||
|
const LEASE_ID = EVENT_ID
|
||||||
|
const OPTION_SOURCE_ID = EVENT_ID
|
||||||
|
|
||||||
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
||||||
// grandfathered names are the ones that predate all of this.
|
// grandfathered names are the ones that predate all of this.
|
||||||
@@ -367,6 +430,169 @@ async function resolveAudience(id, params = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Event actions (EVENTS.md §F) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
||||||
|
*
|
||||||
|
* `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is
|
||||||
|
* stripped from an audience and `handler` from a slash command: this is the
|
||||||
|
* object that leaves the process, and the browser's whole relationship with an
|
||||||
|
* action is naming one by id. §F's "a module registers actions server-side and
|
||||||
|
* adds no routes for them" is only true if the functions never ride out.
|
||||||
|
*/
|
||||||
|
const allEventActions = () =>
|
||||||
|
[...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest)
|
||||||
|
|
||||||
|
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
||||||
|
const eventAction = (id) => eventActions.get(id) || null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does anyone register this id right now?
|
||||||
|
*
|
||||||
|
* The authoring path's question, and it is deliberately not `eventAction(id) !==
|
||||||
|
* null` at every call site: a step naming an action whose module is uninstalled
|
||||||
|
* is DORMANT, not an error (§F), and the difference between "never existed" and
|
||||||
|
* "not installed today" is a distinction only the caller can draw.
|
||||||
|
*/
|
||||||
|
const isEventAction = (id) => eventActions.has(id)
|
||||||
|
|
||||||
|
// ── Event budgets, leases and option sources (§F, Phase 7) ─────────────
|
||||||
|
|
||||||
|
/** Every declared budget dimension, in registration order. */
|
||||||
|
const allEventBudgets = () => [...eventBudgets.values()]
|
||||||
|
|
||||||
|
/** One dimension's declaration, or null. The label-and-unit lookup. */
|
||||||
|
const eventBudget = (id) => eventBudgets.get(id) || null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does anyone declare this dimension right now?
|
||||||
|
*
|
||||||
|
* The fail-closed question (org lead, 2026-09-03): a `cost()` naming a dimension
|
||||||
|
* nobody registered is REFUSED — at save, at the dry run and at dispatch. §F's
|
||||||
|
* *"a module cannot spend a budget it did not declare"* is only true if something
|
||||||
|
* asks, and this is what asks.
|
||||||
|
*/
|
||||||
|
const isEventBudget = (id) => eventBudgets.has(id)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every lease declaration WITHOUT its callables — what the catalog serves.
|
||||||
|
*
|
||||||
|
* Stripped for the reason `perform` is stripped from an action: this object
|
||||||
|
* leaves the process, and the browser's whole relationship with a lease is naming
|
||||||
|
* one by id.
|
||||||
|
*/
|
||||||
|
const allEventLeases = () =>
|
||||||
|
[...eventLeases.values()].map(({ read, apply: applyValue, restore, inForce, ...rest }) => rest)
|
||||||
|
|
||||||
|
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
|
||||||
|
const eventLease = (id) => eventLeases.get(id) || null
|
||||||
|
|
||||||
|
// How a targeted lease's reservation ref is written, and the one place that
|
||||||
|
// knows it. `#` is safe as the separator because `EVENT_ID` admits only
|
||||||
|
// `[a-z0-9_.]`, so no lease id can contain one and the split is unambiguous
|
||||||
|
// however odd a module's target string is.
|
||||||
|
const LEASE_TARGET_SEP = '#'
|
||||||
|
|
||||||
|
/** Compose the ref an `override` row is reserved under. */
|
||||||
|
const leaseRef = (id, target) =>
|
||||||
|
target === null || target === undefined || target === '' ? id : `${id}${LEASE_TARGET_SEP}${target}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an `override` row's ref back into the lease and the target it names.
|
||||||
|
*
|
||||||
|
* **Every reader of a ledgered lease needs this, not just the one that wrote
|
||||||
|
* it.** `cleanup.restoreLease` and `ledger.normalise` both look a lease up by
|
||||||
|
* `row.ref`, and both were correct for exactly as long as a ref was a bare lease
|
||||||
|
* id. A targeted row would have missed in both — cleanup reporting a lease no
|
||||||
|
* module registers and refusing to restore a world that really was changed,
|
||||||
|
* which is the worst failure this table has.
|
||||||
|
*/
|
||||||
|
function parseLeaseRef(ref) {
|
||||||
|
const text = String(ref === undefined || ref === null ? '' : ref)
|
||||||
|
const at = text.indexOf(LEASE_TARGET_SEP)
|
||||||
|
if (at < 0) return { id: text, target: null }
|
||||||
|
return { id: text.slice(0, at), target: text.slice(at + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lease an `override` row names, target included. Null when nothing registers it. */
|
||||||
|
function eventLeaseForRef(ref) {
|
||||||
|
const { id, target } = parseLeaseRef(ref)
|
||||||
|
const lease = eventLeases.get(id) || null
|
||||||
|
return lease ? { lease, target } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every option source WITHOUT its resolver — the authoring form's list. */
|
||||||
|
const allEventOptionSources = () =>
|
||||||
|
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
|
||||||
|
|
||||||
|
// The longest search term a source is asked to honour. Bounded rather than
|
||||||
|
// refused: a term this long is a paste rather than a search, and truncating it
|
||||||
|
// still answers something, where refusing would blank a form mid-keystroke.
|
||||||
|
const MAX_OPTION_QUERY = 120
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one option source, or say why not. Never throws.
|
||||||
|
*
|
||||||
|
* **A refusal is not an error here, and that is the design.** §F: a source that
|
||||||
|
* cannot answer degrades its field to free text with a visible warning rather
|
||||||
|
* than blocking the form. So every failure shape — no such source, a throw, a
|
||||||
|
* rejected promise, a non-array — comes back as `{ ok: false, reason }` and the
|
||||||
|
* caller renders a text box. The alternative is an authoring screen that a
|
||||||
|
* module's outage can make unusable, for a field whose value the operator very
|
||||||
|
* often already knows.
|
||||||
|
*
|
||||||
|
* The options are normalised rather than trusted. This array is rendered into a
|
||||||
|
* `<select>`, so an entry with no `value` is dropped instead of becoming an
|
||||||
|
* option that submits the string "undefined", and `label` falls back to the value
|
||||||
|
* rather than to nothing — a dropdown of blank rows is a worse field than the
|
||||||
|
* text box it replaced.
|
||||||
|
*/
|
||||||
|
async function resolveOptionSource(id, { q = '' } = {}) {
|
||||||
|
const entry = eventOptionSources.get(id)
|
||||||
|
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
|
||||||
|
// **The search term is passed to every source and required of none** (Phase
|
||||||
|
// 12b). A source answers a list; one whose catalog is larger than a dropdown
|
||||||
|
// can hold answers a list NARROWED BY a term, and the two are the same
|
||||||
|
// callable because the difference is the source's business rather than the
|
||||||
|
// form's. A resolver that ignores the argument behaves exactly as it did
|
||||||
|
// before this existed, which is what made this additive.
|
||||||
|
//
|
||||||
|
// It exists because the first source with more entries than `MAX_OPTIONS` is
|
||||||
|
// Phase 12b's spawner target — 6,707 spawn points against a 2,000 bound. Every
|
||||||
|
// source before it fitted, so the bound had only ever truncated in theory; a
|
||||||
|
// dropdown quietly missing two thirds of the world is the same failure 12a
|
||||||
|
// named for decoration, and truncation cannot be the answer to it.
|
||||||
|
const term = String(q || '').trim().slice(0, MAX_OPTION_QUERY)
|
||||||
|
let raw
|
||||||
|
try {
|
||||||
|
raw = await entry.resolve({ q: term })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('option source resolver failed', {
|
||||||
|
source: id,
|
||||||
|
owner: entry.owner,
|
||||||
|
message: err.message,
|
||||||
|
})
|
||||||
|
return { ok: false, reason: `"${entry.label}" could not be read` }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return { ok: false, reason: `"${entry.label}" answered with no option list` }
|
||||||
|
}
|
||||||
|
const options = []
|
||||||
|
for (const o of raw) {
|
||||||
|
if (!o || typeof o !== 'object') continue
|
||||||
|
if (o.value === undefined || o.value === null || o.value === '') continue
|
||||||
|
const option = { value: String(o.value), label: String(o.label ?? o.value) }
|
||||||
|
if (o.group) option.group = String(o.group)
|
||||||
|
options.push(option)
|
||||||
|
}
|
||||||
|
// `searchable` is reported so the authoring form knows which field is a
|
||||||
|
// typeahead and which is a plain select. Without it P13 would have to infer it
|
||||||
|
// from whether a truncated list came back, which is a guess that reads
|
||||||
|
// correctly right up until a small shard's spawner list fits.
|
||||||
|
return { ok: true, id, label: entry.label, owner: entry.owner, searchable: entry.searchable, options, q: term }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||||
//
|
//
|
||||||
// Split from the collision checks below on the same line PR 3 drew through
|
// Split from the collision checks below on the same line PR 3 drew through
|
||||||
@@ -745,6 +971,413 @@ function checkAudienceShape(entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Event action shape (EVENTS.md §F) ──────────────────────────────────────
|
||||||
|
|
||||||
|
// Four values, closed, core-owned (§N6). Deliberately NOT "world-read" and
|
||||||
|
// "world-write", which are game words a chess ladder has no use for — and
|
||||||
|
// deliberately not extensible by a module, because the class is what core
|
||||||
|
// derives a step's `on_failure` from (§L) and a module that could invent
|
||||||
|
// `harmless` would be choosing its own retry policy.
|
||||||
|
const ACTION_RISKS = ['notify', 'inspect', 'change', 'irreversible']
|
||||||
|
|
||||||
|
// What core must know in order to clean up after a run (§L). `none` is gone once
|
||||||
|
// done; `self` undoes itself; `ledger` needs a `revert` over the rows core
|
||||||
|
// recorded; `override` is a lease, whose baseline core restores.
|
||||||
|
const ACTION_REVERSIBLE = ['none', 'self', 'ledger', 'override']
|
||||||
|
|
||||||
|
// The same six types a trigger variable uses. One vocabulary over both, because
|
||||||
|
// the authoring form that renders an action param and the template editor that
|
||||||
|
// renders a trigger variable are the same widget over the same six types, and a
|
||||||
|
// second list is a list that drifts.
|
||||||
|
const ACTION_PARAM_TYPES = VARIABLE_TYPES
|
||||||
|
|
||||||
|
// The param name grammar, shared with trigger variables for the same reason: a
|
||||||
|
// param ends up as a key in a JSON object an operator reads.
|
||||||
|
const PARAM_NAME = VARIABLE_NAME
|
||||||
|
|
||||||
|
// The default per-invocation deadline. Ten seconds is §F's own figure and it is
|
||||||
|
// the number the sidecar's own request timeout is set near — long enough for a
|
||||||
|
// round trip through a module, a sidecar and a game tick, short enough that a
|
||||||
|
// wedged action does not hold a step's claim past its lease.
|
||||||
|
const DEFAULT_BUDGET_MS = 10_000
|
||||||
|
// An hour. Not "unlimited by another name": the bound exists so that a typo in a
|
||||||
|
// declaration is a slow action rather than a step that never times out at all,
|
||||||
|
// and Phase 2's lease has to be longer than this to mean anything.
|
||||||
|
const MAX_BUDGET_MS = 3_600_000
|
||||||
|
|
||||||
|
function checkActionParam(actionId, entry, seen) {
|
||||||
|
const { name, type, required, example, description, source } = entry || {}
|
||||||
|
const where = `registerEventActions: ${actionId}`
|
||||||
|
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
|
||||||
|
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
|
||||||
|
seen.add(name)
|
||||||
|
if (!ACTION_PARAM_TYPES.includes(type)) {
|
||||||
|
throw new Error(`${where}: param "${name}" has unsupported type "${type}"`)
|
||||||
|
}
|
||||||
|
// REQUIRED, on every param including the optional ones, and it is the same
|
||||||
|
// argument `checkTriggerVariable` makes: without it the authoring form has no
|
||||||
|
// placeholder and the operator is typing into a blank box, which is exactly
|
||||||
|
// how an unattended world write comes to be scheduled with a typo in it. It is
|
||||||
|
// one word at declaration time and unreconstructable afterwards.
|
||||||
|
if (example === undefined || example === null || example === '') {
|
||||||
|
throw new Error(`${where}: param "${name}" needs an example (it is the authoring placeholder)`)
|
||||||
|
}
|
||||||
|
// A `source` names a module-served option endpoint, so the field is a dropdown
|
||||||
|
// of real values rather than a text box (§F "Param option sources"). It is
|
||||||
|
// checked as an id here and resolved nowhere yet — the endpoint that answers it
|
||||||
|
// is Phase 7's, and a `source` naming nothing degrades the field to free text
|
||||||
|
// with a warning rather than blocking the form.
|
||||||
|
if (source !== undefined && !ACTION_ID.test(source || '')) {
|
||||||
|
throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
required: Boolean(required),
|
||||||
|
example,
|
||||||
|
source: source === undefined ? null : source,
|
||||||
|
description: description || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile }])`.
|
||||||
|
*
|
||||||
|
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
||||||
|
* the argument alone is decided here, at the call; the collision — is this id
|
||||||
|
* already someone's action? — waits for `apply()`.
|
||||||
|
*
|
||||||
|
* The copy at the end is explicit rather than a spread, like every other shape
|
||||||
|
* check in this file: this object is served to the admin catalog and is what a
|
||||||
|
* step's params are validated against, so anything not named here is not part of
|
||||||
|
* the contract and must not ride along.
|
||||||
|
*
|
||||||
|
* **Nothing here executes and nothing here may touch the database.** Registration
|
||||||
|
* runs under `routeManifest.js` and `swagger.js` against a dead pool
|
||||||
|
* (MODULE_API.md §2.2), and core's own registration is subject to the same rule
|
||||||
|
* as a module's.
|
||||||
|
*/
|
||||||
|
function checkEventActionShape(entry) {
|
||||||
|
const a = entry || {}
|
||||||
|
if (!ACTION_ID.test(a.id || '')) {
|
||||||
|
throw new Error(`registerEventActions: bad action id "${a.id}"`)
|
||||||
|
}
|
||||||
|
if (!a.label) throw new Error(`registerEventActions: action "${a.id}" has no label`)
|
||||||
|
|
||||||
|
// Both required with no default, for the reason a trigger's ceiling is: there
|
||||||
|
// is no safe value to guess. Defaulting `risk` to `notify` would give a world
|
||||||
|
// write the retry policy of a broadcast, and defaulting `reversible` to `none`
|
||||||
|
// would tell the cleanup generator there is nothing to undo.
|
||||||
|
if (!ACTION_RISKS.includes(a.risk)) {
|
||||||
|
throw new Error(
|
||||||
|
`registerEventActions: ${a.id} needs a risk class, one of ${ACTION_RISKS.join(', ')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!ACTION_REVERSIBLE.includes(a.reversible)) {
|
||||||
|
throw new Error(
|
||||||
|
`registerEventActions: ${a.id} needs a reversible class, one of ${ACTION_REVERSIBLE.join(', ')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof a.perform !== 'function') {
|
||||||
|
throw new Error(`registerEventActions: ${a.id} has no perform()`)
|
||||||
|
}
|
||||||
|
// §F: `revert` is required iff `reversible === 'ledger'`. Checked here rather
|
||||||
|
// than discovered at teardown, because the moment it matters is the moment a
|
||||||
|
// run has already created something and the answer "there is no undo" is the
|
||||||
|
// one answer cleanup cannot act on.
|
||||||
|
if (a.reversible === 'ledger' && typeof a.revert !== 'function') {
|
||||||
|
throw new Error(`registerEventActions: ${a.id} is reversible: 'ledger' but has no revert()`)
|
||||||
|
}
|
||||||
|
// The mirror check, and it is not pedantry: a `revert` on a `reversible:
|
||||||
|
// 'none'` action is a module author who believes their action can be undone
|
||||||
|
// and a cleanup generator that will never call it. Silence there is a promise
|
||||||
|
// core does not keep.
|
||||||
|
if (a.revert !== undefined && a.reversible !== 'ledger') {
|
||||||
|
throw new Error(
|
||||||
|
`registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// §L's reconnect row, and it is OPTIONAL where `revert` is required (Phase 8).
|
||||||
|
// `revert` is how a run gives a resource back; `reconcile` is how a module says
|
||||||
|
// which of them the game still has after something outside core restarted. A
|
||||||
|
// module that cannot answer that question is not broken -- core simply keeps
|
||||||
|
// believing its own ledger, which is the pre-Phase-8 behaviour -- whereas a
|
||||||
|
// module that created something and cannot undo it has made a promise core has
|
||||||
|
// no way to keep. Only meaningful for an action that ledgers anything.
|
||||||
|
if (a.reconcile !== undefined) {
|
||||||
|
if (typeof a.reconcile !== 'function') {
|
||||||
|
throw new Error(`registerEventActions: ${a.id} reconcile must be a function`)
|
||||||
|
}
|
||||||
|
if (a.reversible === 'none' || a.reversible === 'self') {
|
||||||
|
throw new Error(
|
||||||
|
`registerEventActions: ${a.id} declares reconcile() but is reversible: '${a.reversible}' and ledgers nothing`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (a.cost !== undefined && typeof a.cost !== 'function') {
|
||||||
|
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = a.version === undefined ? 1 : a.version
|
||||||
|
if (!Number.isInteger(version) || version < 1) {
|
||||||
|
throw new Error(`registerEventActions: ${a.id} has a bad version "${a.version}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetMs = a.budgetMs === undefined ? DEFAULT_BUDGET_MS : a.budgetMs
|
||||||
|
if (!Number.isInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_BUDGET_MS) {
|
||||||
|
throw new Error(
|
||||||
|
`registerEventActions: ${a.id} budgetMs must be 1..${MAX_BUDGET_MS} ms, got "${a.budgetMs}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.params !== undefined && !Array.isArray(a.params)) {
|
||||||
|
throw new Error(`registerEventActions: ${a.id} params must be an array`)
|
||||||
|
}
|
||||||
|
const seen = new Set()
|
||||||
|
const params = (a.params || []).map((p) => checkActionParam(a.id, p, seen))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
label: a.label,
|
||||||
|
description: a.description || '',
|
||||||
|
risk: a.risk,
|
||||||
|
reversible: a.reversible,
|
||||||
|
version,
|
||||||
|
budgetMs,
|
||||||
|
params,
|
||||||
|
cost: a.cost || null,
|
||||||
|
perform: a.perform,
|
||||||
|
revert: a.revert || null,
|
||||||
|
reconcile: a.reconcile || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lease's value type. Closed, like `risk` and `reversible`, and for the same
|
||||||
|
// reason: core validates an operator's input against it at authoring time, so a
|
||||||
|
// type core does not know is a lease core cannot bound.
|
||||||
|
const LEASE_TYPES = ['int', 'float', 'bool', 'string']
|
||||||
|
|
||||||
|
// Thirty days. A lease is a promise the game side keeps WITHOUT being asked again
|
||||||
|
// (§F), so its ceiling is the longest outage a restore may have to survive rather
|
||||||
|
// than a scheduling convenience. Past that, "temporary" has stopped meaning
|
||||||
|
// anything an operator can hold in their head.
|
||||||
|
const MAX_LEASE_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `registerEventBudgets([{ id, label, unit, description }])`.
|
||||||
|
*
|
||||||
|
* A dimension of consumption core can bound. Data only — the module says a
|
||||||
|
* dimension exists and what to call it, `cost()` says how much of it a step
|
||||||
|
* spends, and core owns the arithmetic in between (§F, *"cost is declared by the
|
||||||
|
* module and enforced by core"*).
|
||||||
|
*
|
||||||
|
* **`unit` is required, and its vocabulary is open.** Required because a bare
|
||||||
|
* number on a cap box is ambiguous in exactly the case that matters — 30 of
|
||||||
|
* what? — and open because core never interprets it. It is a display word beside
|
||||||
|
* a number, and closing the set would make "kilometres" a MODULE_API bump for a
|
||||||
|
* noun core does not read.
|
||||||
|
*/
|
||||||
|
function checkEventBudgetShape(entry) {
|
||||||
|
const b = entry || {}
|
||||||
|
if (!BUDGET_ID.test(b.id || '')) {
|
||||||
|
throw new Error(`registerEventBudgets: bad budget id "${b.id}"`)
|
||||||
|
}
|
||||||
|
if (!b.label) throw new Error(`registerEventBudgets: budget "${b.id}" has no label`)
|
||||||
|
if (!b.unit) {
|
||||||
|
throw new Error(`registerEventBudgets: budget "${b.id}" has no unit (it is rendered beside the cap)`)
|
||||||
|
}
|
||||||
|
return { id: b.id, label: b.label, unit: String(b.unit), description: b.description || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }])`.
|
||||||
|
*
|
||||||
|
* A value a run may borrow and must give back. Core owns the duration and the
|
||||||
|
* conflict check; the module owns reading the current value and writing a new one
|
||||||
|
* — the split §F draws, and the reason all three callables are required rather
|
||||||
|
* than one of them.
|
||||||
|
*
|
||||||
|
* **`restore` is required even though `read` could stand in for it.** They answer
|
||||||
|
* different questions: `read` is *"what is it now"*, `restore` is *"put this back,
|
||||||
|
* and tell me if someone else has moved it"* — the drift check, which is the one
|
||||||
|
* thing a module must not be allowed to skip. A lease whose restore writes blindly
|
||||||
|
* is a lease that silently reverts an operator's manual fix.
|
||||||
|
*
|
||||||
|
* **A lease is acquired by `core.lease` and by nothing else** (Phase 8). The step
|
||||||
|
* names a lease id, a value and a duration; core reads the baseline, reserves the
|
||||||
|
* target in `event_run_resources` — which is where the two-events-one-target
|
||||||
|
* refusal comes from — applies the value with the deadline, and restores it at
|
||||||
|
* teardown through the same `restore()` the drift check lives in. A module ships
|
||||||
|
* the three callables and never has to own any of that.
|
||||||
|
*/
|
||||||
|
function checkEventLeaseShape(entry) {
|
||||||
|
const l = entry || {}
|
||||||
|
if (!LEASE_ID.test(l.id || '')) throw new Error(`registerEventLeases: bad lease id "${l.id}"`)
|
||||||
|
if (!l.label) throw new Error(`registerEventLeases: lease "${l.id}" has no label`)
|
||||||
|
if (!LEASE_TYPES.includes(l.type)) {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} needs a type, one of ${LEASE_TYPES.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the numeric types carry a range, and for those it is REQUIRED. A lease
|
||||||
|
// on a rate multiplier with no bounds is an operator one keystroke away from
|
||||||
|
// setting a shard's skill gain to 5000, which is the class of accident the
|
||||||
|
// whole cap machinery exists to make impossible — and unlike a cap, a bad lease
|
||||||
|
// value is in force the moment it is applied.
|
||||||
|
let min = null
|
||||||
|
let max = null
|
||||||
|
if (l.type === 'int' || l.type === 'float') {
|
||||||
|
min = Number(l.min)
|
||||||
|
max = Number(l.max)
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} is ${l.type} and needs a numeric min and max`)
|
||||||
|
}
|
||||||
|
if (l.type === 'int' && (!Number.isInteger(min) || !Number.isInteger(max))) {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} is int and needs whole-number min and max`)
|
||||||
|
}
|
||||||
|
if (min > max) throw new Error(`registerEventLeases: ${l.id} has min ${min} above max ${max}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDurationMs = l.maxDurationMs
|
||||||
|
if (!Number.isInteger(maxDurationMs) || maxDurationMs <= 0 || maxDurationMs > MAX_LEASE_MS) {
|
||||||
|
throw new Error(
|
||||||
|
`registerEventLeases: ${l.id} maxDurationMs must be 1..${MAX_LEASE_MS} ms, got "${l.maxDurationMs}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const fn of ['read', 'apply', 'restore']) {
|
||||||
|
if (typeof l[fn] !== 'function') throw new Error(`registerEventLeases: ${l.id} has no ${fn}()`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// **`inForce()` is optional, and it is the fourth question a lease can answer**
|
||||||
|
// (Phase 11b). `read` is "what is it now", `apply` is "hold it here", `restore`
|
||||||
|
// is "put it back and tell me if somebody moved it" — and none of the three
|
||||||
|
// answers "does the game side still have any record of this hold?", which is
|
||||||
|
// what a reconcile after an outage needs.
|
||||||
|
//
|
||||||
|
// It is deliberately not `read()` with a comparison. A value that differs from
|
||||||
|
// what the event applied is DRIFT, and drift is a verdict teardown has to
|
||||||
|
// deliver through `restore` so the resource lands as `drifted`; a reconcile
|
||||||
|
// that inferred absence from a changed value would orphan the row first and
|
||||||
|
// destroy the one signal an operator needs. The two questions have different
|
||||||
|
// answers on purpose.
|
||||||
|
//
|
||||||
|
// Optional because the fallback is the posture core takes everywhere else: a
|
||||||
|
// lease that cannot say leaves its ledger row alone, which is exactly the
|
||||||
|
// behaviour before this phase.
|
||||||
|
if (l.inForce !== undefined && typeof l.inForce !== 'function') {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} inForce must be a function`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A TARGETED lease is a family of values rather than one** (Phase 12b).
|
||||||
|
//
|
||||||
|
// Every lease before this one named a single value — a config key, a rate —
|
||||||
|
// and the lease id WAS the target, which is why the four callables took none
|
||||||
|
// and why the reservation ref was the id alone. An object property is not that
|
||||||
|
// shape: `Spawner.MaxCount` is one lease over thousands of spawners, and two
|
||||||
|
// runs turning up two DIFFERENT spawners must both be allowed while two runs
|
||||||
|
// turning up the same one must not.
|
||||||
|
//
|
||||||
|
// So a lease may declare a target, and when it does core adds one to the
|
||||||
|
// reservation ref (`<id>#<target>`) and hands it to every callable. The
|
||||||
|
// two-events-one-target refusal then comes from the same unique index it
|
||||||
|
// always did, at the granularity the world actually has. **Core still owns the
|
||||||
|
// duration bound and the conflict check** — which is the whole of why §F put
|
||||||
|
// the verb in core, and the reason this is an extension of `core.lease` rather
|
||||||
|
// than a lease verb of the module's own.
|
||||||
|
let target = null
|
||||||
|
if (l.target !== undefined && l.target !== null) {
|
||||||
|
const t = l.target
|
||||||
|
if (typeof t !== 'object' || Array.isArray(t)) {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} target must be an object`)
|
||||||
|
}
|
||||||
|
if (!t.label) throw new Error(`registerEventLeases: ${l.id} target has no label`)
|
||||||
|
// The source is checked for shape only, never for existence, on
|
||||||
|
// `resolveOptionSource`'s own argument: a source that cannot answer degrades
|
||||||
|
// its field to free text rather than blocking the form, and a source
|
||||||
|
// registered by a module that boots later must not make this one throw.
|
||||||
|
if (t.source !== undefined && !OPTION_SOURCE_ID.test(t.source || '')) {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} target names a bad option source "${t.source}"`)
|
||||||
|
}
|
||||||
|
target = {
|
||||||
|
label: String(t.label),
|
||||||
|
source: t.source || null,
|
||||||
|
example: t.example === undefined ? null : String(t.example),
|
||||||
|
description: t.description || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A closed set of values, for the type that has no range** (Phase 12b).
|
||||||
|
// `min`/`max` bound the numeric types and nothing bounded `string`, so a string
|
||||||
|
// lease was free text validated only by the game side — which means an invalid
|
||||||
|
// value is a refusal at DISPATCH, unattended, halfway through a run, instead of
|
||||||
|
// a refusal on the form. Optional, because a genuinely free-text lease still
|
||||||
|
// exists; enforced by `coerceLeaseValue` when present.
|
||||||
|
let values = null
|
||||||
|
if (l.values !== undefined && l.values !== null) {
|
||||||
|
if (!Array.isArray(l.values) || !l.values.length) {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} values must be a non-empty array`)
|
||||||
|
}
|
||||||
|
if (l.type !== 'string') {
|
||||||
|
throw new Error(`registerEventLeases: ${l.id} is ${l.type}; values is for string leases`)
|
||||||
|
}
|
||||||
|
values = l.values.map((v) => String(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: l.id,
|
||||||
|
label: l.label,
|
||||||
|
description: l.description || '',
|
||||||
|
type: l.type,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
values,
|
||||||
|
target,
|
||||||
|
maxDurationMs,
|
||||||
|
read: l.read,
|
||||||
|
apply: l.apply,
|
||||||
|
restore: l.restore,
|
||||||
|
inForce: l.inForce || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `registerEventOptionSources([{ id, label, description, resolve }])`.
|
||||||
|
*
|
||||||
|
* The values behind a param's `source` (§F, *Param option sources*). Its own
|
||||||
|
* registration rather than a field on the action that names it, because a catalog
|
||||||
|
* has more than one consumer: `uo.options.items` is the allowlist for granting an
|
||||||
|
* item and for taking one back, and two actions declaring it separately would be
|
||||||
|
* two allowlists that can disagree.
|
||||||
|
*
|
||||||
|
* `resolve()` answers `[{ value, label, group? }]`. It may be async, it may talk
|
||||||
|
* to a sidecar, and it may fail — the failure is handled at the call
|
||||||
|
* (`resolveOptionSource`) rather than here, because the answer to a source that
|
||||||
|
* cannot answer is a text box, not a broken form.
|
||||||
|
*/
|
||||||
|
function checkEventOptionSourceShape(entry) {
|
||||||
|
const s = entry || {}
|
||||||
|
if (!OPTION_SOURCE_ID.test(s.id || '')) {
|
||||||
|
throw new Error(`registerEventOptionSources: bad option source id "${s.id}"`)
|
||||||
|
}
|
||||||
|
if (!s.label) throw new Error(`registerEventOptionSources: source "${s.id}" has no label`)
|
||||||
|
if (typeof s.resolve !== 'function') {
|
||||||
|
throw new Error(`registerEventOptionSources: ${s.id} has no resolve()`)
|
||||||
|
}
|
||||||
|
// **A declaration, not an inference** (Phase 12b). `resolve({ q })` is passed a
|
||||||
|
// term whether or not the source reads it, so nothing here can tell from the
|
||||||
|
// callable alone whether a term would narrow the answer. The module says, and
|
||||||
|
// the authoring form renders a typeahead rather than a select for the ones
|
||||||
|
// that do.
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
description: s.description || '',
|
||||||
|
searchable: s.searchable === true,
|
||||||
|
resolve: s.resolve,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
|
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
|
||||||
//
|
//
|
||||||
// **Two mechanisms, and the asymmetry between them is the whole design.**
|
// **Two mechanisms, and the asymmetry between them is the whole design.**
|
||||||
@@ -983,7 +1616,11 @@ function stage(owner) {
|
|||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
triggers: [],
|
triggers: [],
|
||||||
audiences: [],
|
audiences: [],
|
||||||
|
eventActions: [],
|
||||||
engagementSeeds: [],
|
engagementSeeds: [],
|
||||||
|
eventBudgets: [],
|
||||||
|
eventLeases: [],
|
||||||
|
eventOptionSources: [],
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
staged,
|
staged,
|
||||||
@@ -1015,6 +1652,33 @@ function stage(owner) {
|
|||||||
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
|
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
|
||||||
for (const e of entries) staged.audiences.push(checkAudienceShape(e))
|
for (const e of entries) staged.audiences.push(checkAudienceShape(e))
|
||||||
},
|
},
|
||||||
|
// EVENTS.md §F, Phase 1. Present on the staging area from this phase and
|
||||||
|
// reached ONLY by `registerCore()` below — `loader.js` builds its own `api`
|
||||||
|
// facade and has no method that delegates here, so a module cannot call this
|
||||||
|
// yet. Phase 7 adds that facade and bumps MODULE_API to 1.10.0; until then
|
||||||
|
// the seam is exercised on every boot by core's own three actions and by
|
||||||
|
// nothing else, which is the point of registering them through it.
|
||||||
|
registerEventActions(entries) {
|
||||||
|
if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array')
|
||||||
|
for (const e of entries) staged.eventActions.push(checkEventActionShape(e))
|
||||||
|
},
|
||||||
|
// The three that arrive WITH the module-facing seam (Phase 7). Unlike
|
||||||
|
// `registerEventActions` above, these have never had a core-only period:
|
||||||
|
// `loader.js` forwards all four from the boot this lands on, and core
|
||||||
|
// registers through them on the same boot, which is the posture
|
||||||
|
// `registerCore()` has taken since the module system's Phase 3.
|
||||||
|
registerEventBudgets(entries) {
|
||||||
|
if (!Array.isArray(entries)) throw new Error('registerEventBudgets: expected an array')
|
||||||
|
for (const e of entries) staged.eventBudgets.push(checkEventBudgetShape(e))
|
||||||
|
},
|
||||||
|
registerEventLeases(entries) {
|
||||||
|
if (!Array.isArray(entries)) throw new Error('registerEventLeases: expected an array')
|
||||||
|
for (const e of entries) staged.eventLeases.push(checkEventLeaseShape(e))
|
||||||
|
},
|
||||||
|
registerEventOptionSources(entries) {
|
||||||
|
if (!Array.isArray(entries)) throw new Error('registerEventOptionSources: expected an array')
|
||||||
|
for (const e of entries) staged.eventOptionSources.push(checkEventOptionSourceShape(e))
|
||||||
|
},
|
||||||
registerEngagementSeeds(entry) {
|
registerEngagementSeeds(entry) {
|
||||||
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
|
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
|
||||||
},
|
},
|
||||||
@@ -1040,6 +1704,10 @@ function apply({
|
|||||||
slashCommands: newSlashCommands = [],
|
slashCommands: newSlashCommands = [],
|
||||||
triggers: newTriggers = [],
|
triggers: newTriggers = [],
|
||||||
audiences: newAudiences = [],
|
audiences: newAudiences = [],
|
||||||
|
eventActions: newEventActions = [],
|
||||||
|
eventBudgets: newEventBudgets = [],
|
||||||
|
eventLeases: newEventLeases = [],
|
||||||
|
eventOptionSources: newEventOptionSources = [],
|
||||||
engagementSeeds: newSeeds = [],
|
engagementSeeds: newSeeds = [],
|
||||||
}) {
|
}) {
|
||||||
// ── validate ──
|
// ── validate ──
|
||||||
@@ -1096,6 +1764,65 @@ function apply({
|
|||||||
seenAudiences.add(a.id)
|
seenAudiences.add(a.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Actions, against their OWN map and nothing else. No cross-facet check with
|
||||||
|
// streams or triggers: an action id and a trigger id are different namespaces
|
||||||
|
// (§F), so `uo.champ.start` may legitimately be both a verb and an event, and
|
||||||
|
// reading a collision there would forbid the most natural pair of names a
|
||||||
|
// module will ever want. No legacy allowlist either — nothing predates this,
|
||||||
|
// so the prefix rule has no exceptions and should never grow one.
|
||||||
|
const seenActions = new Set()
|
||||||
|
for (const a of newEventActions) {
|
||||||
|
const held = eventActions.get(a.id)
|
||||||
|
if (held) throw new Error(`event action "${a.id}" is already registered by "${held.owner}"`)
|
||||||
|
if (seenActions.has(a.id)) throw new Error(`event action "${a.id}" registered twice`)
|
||||||
|
if (!namespaced(owner, a.id, {})) {
|
||||||
|
throw new Error(`event action "${a.id}" is not namespaced "${owner}."`)
|
||||||
|
}
|
||||||
|
seenActions.add(a.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budgets, leases and option sources: three more id spaces, checked against
|
||||||
|
// their own maps and against nothing else, for the reason the actions loop
|
||||||
|
// above gives. No legacy allowlist on any of the three — nothing predates them,
|
||||||
|
// so the prefix rule has no exceptions and should never grow one.
|
||||||
|
//
|
||||||
|
// The one cross-facet check that would be wrong here is budget-against-action:
|
||||||
|
// §F puts them in separate id spaces deliberately, and `uo.creatures` as a
|
||||||
|
// dimension beside `uo.creature.spawn` as a verb is the most natural pair of
|
||||||
|
// names a module will ever write.
|
||||||
|
const seenBudgets = new Set()
|
||||||
|
for (const b of newEventBudgets) {
|
||||||
|
const held = eventBudgets.get(b.id)
|
||||||
|
if (held) throw new Error(`event budget "${b.id}" is already registered by "${held.owner}"`)
|
||||||
|
if (seenBudgets.has(b.id)) throw new Error(`event budget "${b.id}" registered twice`)
|
||||||
|
if (!namespaced(owner, b.id, {})) {
|
||||||
|
throw new Error(`event budget "${b.id}" is not namespaced "${owner}."`)
|
||||||
|
}
|
||||||
|
seenBudgets.add(b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenLeases = new Set()
|
||||||
|
for (const l of newEventLeases) {
|
||||||
|
const held = eventLeases.get(l.id)
|
||||||
|
if (held) throw new Error(`event lease "${l.id}" is already registered by "${held.owner}"`)
|
||||||
|
if (seenLeases.has(l.id)) throw new Error(`event lease "${l.id}" registered twice`)
|
||||||
|
if (!namespaced(owner, l.id, {})) {
|
||||||
|
throw new Error(`event lease "${l.id}" is not namespaced "${owner}."`)
|
||||||
|
}
|
||||||
|
seenLeases.add(l.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenSources = new Set()
|
||||||
|
for (const s of newEventOptionSources) {
|
||||||
|
const held = eventOptionSources.get(s.id)
|
||||||
|
if (held) throw new Error(`option source "${s.id}" is already registered by "${held.owner}"`)
|
||||||
|
if (seenSources.has(s.id)) throw new Error(`option source "${s.id}" registered twice`)
|
||||||
|
if (!namespaced(owner, s.id, {})) {
|
||||||
|
throw new Error(`option source "${s.id}" is not namespaced "${owner}."`)
|
||||||
|
}
|
||||||
|
seenSources.add(s.id)
|
||||||
|
}
|
||||||
|
|
||||||
const seenLegs = new Set()
|
const seenLegs = new Set()
|
||||||
for (const l of newLegs) {
|
for (const l of newLegs) {
|
||||||
const held = legs.get(l.leg)
|
const held = legs.get(l.leg)
|
||||||
@@ -1160,6 +1887,10 @@ function apply({
|
|||||||
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
||||||
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
|
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
|
||||||
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
|
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
|
||||||
|
for (const a of newEventActions) eventActions.set(a.id, { owner, ...a })
|
||||||
|
for (const b of newEventBudgets) eventBudgets.set(b.id, { owner, ...b })
|
||||||
|
for (const l of newEventLeases) eventLeases.set(l.id, { owner, ...l })
|
||||||
|
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
|
||||||
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
|
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,6 +1913,7 @@ function registerCore() {
|
|||||||
/* eslint-disable global-require */
|
/* eslint-disable global-require */
|
||||||
const coreStreams = require('../config/coreStreams')
|
const coreStreams = require('../config/coreStreams')
|
||||||
const coreTriggers = require('../config/coreTriggers')
|
const coreTriggers = require('../config/coreTriggers')
|
||||||
|
const coreEventActions = require('../config/coreEventActions')
|
||||||
const discordLeg = require('../utils/discordAnnounce')
|
const discordLeg = require('../utils/discordAnnounce')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -1192,6 +1924,18 @@ function registerCore() {
|
|||||||
// its five stream ids — the same-owner upgrade the one-namespace rule above is
|
// its five stream ids — the same-owner upgrade the one-namespace rule above is
|
||||||
// written for — so this batch exercises the cross-facet check on every boot.
|
// written for — so this batch exercises the cross-facet check on every boot.
|
||||||
api.registerEventTriggers(coreTriggers.TRIGGERS)
|
api.registerEventTriggers(coreTriggers.TRIGGERS)
|
||||||
|
// The event contract (EVENTS.md §F, Phase 1). Core registers `core.announce`,
|
||||||
|
// `core.wait` and `core.cue` through the SAME staging area Phase 7 will hand a
|
||||||
|
// module, so the registry is exercised on every boot long before a module uses
|
||||||
|
// it — the argument registerCore() has made since the module system's Phase 3.
|
||||||
|
api.registerEventActions(coreEventActions.ACTIONS)
|
||||||
|
// And core's own option source (Phase 7, org lead 2026-09-03). `core.announce`
|
||||||
|
// takes a leg id, and until now that was a free-text box whose typo was caught
|
||||||
|
// at DISPATCH, mid-run — which is exactly the defect Phase 6's walk hit, an
|
||||||
|
// announce leg "site" no module registers. The legs are already in a registry
|
||||||
|
// with their labels, so the dropdown costs nothing new, and core registering an
|
||||||
|
// option source means the seam's first exercise is not a module's.
|
||||||
|
api.registerEventOptionSources(coreEventActions.OPTION_SOURCES)
|
||||||
|
|
||||||
// The three lines that used to follow — the shard stream catalog, the town
|
// The three lines that used to follow — the shard stream catalog, the town
|
||||||
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
|
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
|
||||||
@@ -1205,6 +1949,8 @@ function registerCore() {
|
|||||||
log.info('core registrations complete', {
|
log.info('core registrations complete', {
|
||||||
streams: streams.length,
|
streams: streams.length,
|
||||||
eventTriggers: triggers.size,
|
eventTriggers: triggers.size,
|
||||||
|
eventActions: eventActions.size,
|
||||||
|
eventOptionSources: eventOptionSources.size,
|
||||||
announceLegs: legs.size,
|
announceLegs: legs.size,
|
||||||
extensions: [...slots.keys()].filter(slotFilledBy),
|
extensions: [...slots.keys()].filter(slotFilledBy),
|
||||||
})
|
})
|
||||||
@@ -1236,6 +1982,10 @@ function _reset() {
|
|||||||
slashCommands.clear()
|
slashCommands.clear()
|
||||||
triggers.clear()
|
triggers.clear()
|
||||||
audiences.clear()
|
audiences.clear()
|
||||||
|
eventActions.clear()
|
||||||
|
eventBudgets.clear()
|
||||||
|
eventLeases.clear()
|
||||||
|
eventOptionSources.clear()
|
||||||
engagementSeeds.clear()
|
engagementSeeds.clear()
|
||||||
coreRegistered = false
|
coreRegistered = false
|
||||||
}
|
}
|
||||||
@@ -1264,11 +2014,30 @@ module.exports = {
|
|||||||
allAudiences,
|
allAudiences,
|
||||||
audience,
|
audience,
|
||||||
resolveAudience,
|
resolveAudience,
|
||||||
|
allEventActions,
|
||||||
|
eventAction,
|
||||||
|
isEventAction,
|
||||||
|
allEventBudgets,
|
||||||
|
eventBudget,
|
||||||
|
isEventBudget,
|
||||||
|
allEventLeases,
|
||||||
|
eventLease,
|
||||||
|
eventLeaseForRef,
|
||||||
|
parseLeaseRef,
|
||||||
|
leaseRef,
|
||||||
|
allEventOptionSources,
|
||||||
|
resolveOptionSource,
|
||||||
allEngagementSeeds,
|
allEngagementSeeds,
|
||||||
engagementSeedsFor,
|
engagementSeedsFor,
|
||||||
SEEDABLE_CHANNELS,
|
SEEDABLE_CHANNELS,
|
||||||
VARIABLE_TYPES,
|
VARIABLE_TYPES,
|
||||||
TRIGGER_KINDS,
|
TRIGGER_KINDS,
|
||||||
|
ACTION_RISKS,
|
||||||
|
ACTION_REVERSIBLE,
|
||||||
|
ACTION_PARAM_TYPES,
|
||||||
|
LEASE_TYPES,
|
||||||
|
MAX_LEASE_MS,
|
||||||
|
DEFAULT_BUDGET_MS,
|
||||||
stage,
|
stage,
|
||||||
apply,
|
apply,
|
||||||
registerCore,
|
registerCore,
|
||||||
|
|||||||
@@ -9,6 +9,27 @@
|
|||||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
// 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.
|
// 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,
|
// 1.9.0 - a sixth registration call: `api.registerEngagementSeeds({ templates,
|
||||||
// ruleGroups })` (docs/website/ENGAGEMENT.md Phase 11b, decision 7). A module
|
// 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
|
// could declare a trigger from 1.7.0 and could never say what the mail should
|
||||||
@@ -134,6 +155,6 @@
|
|||||||
// an admin action a module performs belongs in core's one audit log, the
|
// 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
|
// 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.
|
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||||
const MODULE_API_VERSION = '1.9.0'
|
const MODULE_API_VERSION = '1.10.0'
|
||||||
|
|
||||||
module.exports = { MODULE_API_VERSION }
|
module.exports = { MODULE_API_VERSION }
|
||||||
|
|||||||
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 settingsRouter = require('./settings.router')
|
||||||
const modulesRouter = require('./modules.router')
|
const modulesRouter = require('./modules.router')
|
||||||
const engagementRouter = require('./engagement.router')
|
const engagementRouter = require('./engagement.router')
|
||||||
|
const eventsRouter = require('./events.router')
|
||||||
const teamsRouter = require('./teams.router')
|
const teamsRouter = require('./teams.router')
|
||||||
const teamsVoiceRouter = require('./teamsVoice.router')
|
const teamsVoiceRouter = require('./teamsVoice.router')
|
||||||
const dashboardRouter = require('./dashboard.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
|
// /modules above and for a related reason: this is the surface that decides who
|
||||||
// the site sends mail to.
|
// the site sends mail to.
|
||||||
adminRouter.use('/engagement', engagementRouter)
|
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
|
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
|
||||||
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
|
// 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,
|
// 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 appealsRouter = require('./appeals.router')
|
||||||
const teamsRouter = require('./teams.router')
|
const teamsRouter = require('./teams.router')
|
||||||
const teamForumRouter = require('./teamForum.router')
|
const teamForumRouter = require('./teamForum.router')
|
||||||
|
const eventsRouter = require('./events.router')
|
||||||
|
|
||||||
const playerRouter = express.Router()
|
const playerRouter = express.Router()
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ const playerRouter = express.Router()
|
|||||||
playerRouter.use(noindex, requireAuth)
|
playerRouter.use(noindex, requireAuth)
|
||||||
|
|
||||||
playerRouter.use('/appeals', appealsRouter)
|
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)
|
playerRouter.use('/teams', teamsRouter)
|
||||||
// Same prefix, second router. The forum and the leader-exercised grant flow are a
|
// 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
|
// 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 pagesRouter = require('./pages.router')
|
||||||
const modulesRouter = require('./modules.router')
|
const modulesRouter = require('./modules.router')
|
||||||
const teamsRouter = require('./teams.router')
|
const teamsRouter = require('./teams.router')
|
||||||
|
const eventsRouter = require('./events.router')
|
||||||
const engagementRouter = require('./engagement.router')
|
const engagementRouter = require('./engagement.router')
|
||||||
const siteRouter = require('./site.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
|
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
|
||||||
// content above it.
|
// content above it.
|
||||||
publicRouter.use('/teams', teamsRouter)
|
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
|
// The unauthenticated half of the engagement system: today exactly the
|
||||||
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
|
// 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
|
// token names is a channel and a scope and a scope is not always a Team
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const engagementRetentionPrune = require('./utils/engagementRetentionPrune')
|
|||||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||||
const teamDigestWorker = require('./utils/teamDigestWorker')
|
const teamDigestWorker = require('./utils/teamDigestWorker')
|
||||||
const engagementWorker = require('./utils/engagementWorker')
|
const engagementWorker = require('./utils/engagementWorker')
|
||||||
|
const eventRunner = require('./utils/eventRunner')
|
||||||
|
const eventCleanup = require('./events/cleanup')
|
||||||
const { ensureSchema, close } = require('./utils/db')
|
const { ensureSchema, close } = require('./utils/db')
|
||||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
@@ -169,6 +171,25 @@ async function start() {
|
|||||||
// enables a rule: core seeds none and `enabled` defaults to 0.
|
// enables a rule: core seeds none and `enabled` defaults to 0.
|
||||||
engagementWorker.start()
|
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)
|
setupShutdown(server, internalServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +213,7 @@ function setupShutdown(server, internalServer) {
|
|||||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||||
teamDigestWorker.stop() // stop the Team forum digest timer
|
teamDigestWorker.stop() // stop the Team forum digest timer
|
||||||
engagementWorker.stop() // stop the engagement outbox worker
|
engagementWorker.stop() // stop the engagement outbox worker
|
||||||
|
eventRunner.stop() // stop the event runner
|
||||||
server.close(() => log.info('http server closed'))
|
server.close(() => log.info('http server closed'))
|
||||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
// silently loses a variable is a template that silently renders `undefined`.
|
// silently loses a variable is a template that silently renders `undefined`.
|
||||||
|
|
||||||
const registries = require('../modules/registries')
|
const registries = require('../modules/registries')
|
||||||
|
const ceilings = require('../modules/ceilings')
|
||||||
const engine = require('../engagement/engine')
|
const engine = require('../engagement/engine')
|
||||||
|
const eventGates = require('../events/gates')
|
||||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||||
const createLogger = require('./logger')
|
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`)
|
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)
|
const payload = validatePayload(declaration, data)
|
||||||
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
|
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)]
|
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 (dedupeKey !== undefined && dedupeKey !== null) {
|
||||||
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
|
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
|
||||||
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
|
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,
|
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
|
||||||
scopeKey: resolvedScope,
|
scopeKey: resolvedScope,
|
||||||
recipientUserIds: resolvedRecipients,
|
recipientUserIds: resolvedRecipients,
|
||||||
|
ceiling: resolvedCeiling,
|
||||||
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
|
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
|
||||||
occurredAt: at.toISOString(),
|
occurredAt: at.toISOString(),
|
||||||
data: payload.data,
|
data: payload.data,
|
||||||
@@ -276,6 +306,20 @@ function emit(owner, triggerId, envelope = {}) {
|
|||||||
// await the delivery decision, and the tests use it directly.
|
// await the delivery decision, and the tests use it directly.
|
||||||
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
|
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 }
|
return { ok: true, event }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
File diff suppressed because it is too large
Load Diff
@@ -991,6 +991,13 @@ const doc = {
|
|||||||
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
|
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
|
||||||
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
|
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
|
||||||
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
||||||
|
capabilities: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
example: ['events'],
|
||||||
|
description:
|
||||||
|
'Opaque strings naming what CORE serves beyond the baseline every backend has — the same idea as a module’s `capabilities` on /public/modules, and a separate list because core is not a module. A backend released before a capability existed omits the key entirely, which is how a client tells an older site from one that simply has nothing to show. Treat an unknown string as absent.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PublicModules: {
|
PublicModules: {
|
||||||
@@ -1234,6 +1241,190 @@ const doc = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
PublicEventEntry: {
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
|
||||||
|
properties: {
|
||||||
|
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
|
||||||
|
title: { type: 'string', example: 'The Yew Invasion' },
|
||||||
|
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||||
|
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
|
||||||
|
seriesSlug: { type: 'string', nullable: true, example: 'the-yew-campaign' },
|
||||||
|
scheduledFor: { type: 'string', format: 'date-time', description: 'The instant, UTC.' },
|
||||||
|
timezone: {
|
||||||
|
type: 'string',
|
||||||
|
example: 'America/New_York',
|
||||||
|
description:
|
||||||
|
'The EVENT’s zone, not the reader’s. A shard-local 8pm means the shard’s evening to everyone reading it, so the time is rendered in this zone while the reader’s own zone places the entry in a month grid.',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['scheduled', 'live', 'completed', 'cancelled'],
|
||||||
|
description:
|
||||||
|
'The public status word. `failed` and `missed` are both published as `cancelled`: to a visitor they are one event — it was on the calendar and it did not happen — while the difference between them is about the deployment rather than about the event.',
|
||||||
|
},
|
||||||
|
live: { type: 'boolean', example: false },
|
||||||
|
adjusted: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Projections only: this instant is not the wall clock the schedule names, because a DST change moved it.',
|
||||||
|
},
|
||||||
|
shiftMinutes: { type: 'integer', description: 'Projections only: by how much.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicEventCalendar: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean', example: true },
|
||||||
|
window: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from: { type: 'string', format: 'date-time' },
|
||||||
|
to: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entries: { type: 'array', items: { $ref: '#/components/schemas/PublicEventEntry' } },
|
||||||
|
truncated: { type: 'boolean', description: 'The window held more entries than the cap.', example: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicEventOccurrence: {
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'One occurrence of an event, as a public page lists it. Health, cleanup state, claims and errors are never published — a degraded run is a fact about the deployment’s plumbing, while "the event is running" is the fact about the event.',
|
||||||
|
properties: {
|
||||||
|
runId: { type: 'integer', example: 3692 },
|
||||||
|
scheduledFor: { type: 'string', format: 'date-time' },
|
||||||
|
timezone: { type: 'string', example: 'America/New_York' },
|
||||||
|
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||||||
|
live: { type: 'boolean' },
|
||||||
|
scope: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
description: 'Module-opaque — the shard or server this occurrence ran on, on a deployment that uses them.',
|
||||||
|
},
|
||||||
|
phase: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
example: 'The assault',
|
||||||
|
description:
|
||||||
|
'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
|
||||||
|
},
|
||||||
|
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicEventParticipant: {
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'A results row. The member key is the game’s own identifier for a character and is module-opaque, so core cannot say what publishing one would disclose — it is not published. A display name is whatever the module chose to put in `meta`.',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', nullable: true, example: 'Aldric' },
|
||||||
|
score: { type: 'number', example: 1420 },
|
||||||
|
rank: { type: 'integer', nullable: true, example: 3, description: 'Null until results were published.' },
|
||||||
|
meta: { type: 'object', nullable: true, description: 'Module-opaque: whatever it wanted shown beside a name.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicEvent: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean', example: true },
|
||||||
|
event: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', example: 'The Yew Invasion' },
|
||||||
|
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||||
|
summary: { type: 'string', nullable: true },
|
||||||
|
body: { type: 'string', nullable: true, description: 'The storyline. Sanitized HTML, the treatment a wiki page gets.' },
|
||||||
|
imageUrl: { type: 'string', nullable: true },
|
||||||
|
timezone: { type: 'string', example: 'America/New_York' },
|
||||||
|
series: {
|
||||||
|
type: 'object',
|
||||||
|
nullable: true,
|
||||||
|
properties: { name: { type: 'string' }, slug: { type: 'string' } },
|
||||||
|
},
|
||||||
|
live: { type: 'boolean' },
|
||||||
|
current: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||||||
|
next: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||||||
|
upcoming: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||||||
|
past: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||||||
|
results: {
|
||||||
|
type: 'object',
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Present only once an occurrence has published results. Which occurrence follows `?run=`, then a live one, then the most recent that published any.',
|
||||||
|
properties: {
|
||||||
|
runId: { type: 'integer' },
|
||||||
|
scheduledFor: { type: 'string', format: 'date-time' },
|
||||||
|
publishedAt: { type: 'string', format: 'date-time' },
|
||||||
|
participants: { type: 'array', items: { $ref: '#/components/schemas/PublicEventParticipant' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicEventSeries: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean', example: true },
|
||||||
|
series: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', example: 'The Yew Campaign' },
|
||||||
|
slug: { type: 'string', example: 'the-yew-campaign' },
|
||||||
|
description: { type: 'string', nullable: true },
|
||||||
|
events: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'The listed events in the arc, in the order an editor arranged them.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string' },
|
||||||
|
slug: { type: 'string' },
|
||||||
|
summary: { type: 'string', nullable: true },
|
||||||
|
imageUrl: { type: 'string', nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PlayerEventHistory: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean', example: true },
|
||||||
|
entries: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', description: 'The participation row. Pass the last one as `before` to page.' },
|
||||||
|
runId: { type: 'integer' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
slug: { type: 'string' },
|
||||||
|
seriesName: { type: 'string', nullable: true },
|
||||||
|
seriesSlug: { type: 'string', nullable: true },
|
||||||
|
scheduledFor: { type: 'string', format: 'date-time' },
|
||||||
|
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
timezone: { type: 'string' },
|
||||||
|
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||||||
|
joinedAt: { type: 'string', format: 'date-time' },
|
||||||
|
score: { type: 'number' },
|
||||||
|
rank: {
|
||||||
|
type: 'integer',
|
||||||
|
nullable: true,
|
||||||
|
description: 'Null until results were published — a real state rather than an error.',
|
||||||
|
},
|
||||||
|
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
meta: { type: 'object', nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
PublicTeamMember: {
|
PublicTeamMember: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -701,6 +701,48 @@ test('the ceiling is re-checked at SEND time, so a module narrowing its declarat
|
|||||||
assert.equal(after2.enqueued, 0)
|
assert.equal(after2.enqueued, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a FIRING may narrow the ceiling, and every rule wider than that is refused', async () => {
|
||||||
|
// EVENTS.md §I, and the case that forced it: 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. The declaration is a property of
|
||||||
|
// the KIND of event; this is a property of the occasion.
|
||||||
|
registries._reset()
|
||||||
|
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
|
||||||
|
addUser(11)
|
||||||
|
optIn(10, 'uo.house.idoc_warning', 'email')
|
||||||
|
optIn(11, 'uo.house.idoc_warning', 'email')
|
||||||
|
addRule({ audience: 'authenticated' })
|
||||||
|
|
||||||
|
const wide = await engine.dispatch(event(), T0)
|
||||||
|
assert.equal(wide.enqueued, 2)
|
||||||
|
|
||||||
|
const narrowed = await engine.dispatch(event({ subject: 'house-9', ceiling: 'staff' }), later(1000))
|
||||||
|
assert.equal(narrowed.enqueued, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a firing may only ever NARROW — a wider ceiling than the declaration changes nothing', async () => {
|
||||||
|
registries._reset()
|
||||||
|
registerUoTrigger({ ceiling: 'owner', audience: 'owner' })
|
||||||
|
addRule({ audience: 'owner' })
|
||||||
|
|
||||||
|
// `everyone` is the widest value the lattice has. `meet(owner, everyone)` is
|
||||||
|
// `owner`, so the declaration still governs and the rule still fires.
|
||||||
|
const out = await engine.dispatch(event({ ceiling: 'everyone' }), T0)
|
||||||
|
assert.equal(out.enqueued, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two INCOMPARABLE ceilings meet to nothing and the gate refuses rather than guessing', async () => {
|
||||||
|
// `owner` and `staff` have no common descendant — "fewer people" is not "less
|
||||||
|
// exposure", which is the whole argument `modules/ceilings.js` is built on.
|
||||||
|
// Picking one would be the guess §5.1a rule 3 exists to refuse.
|
||||||
|
registries._reset()
|
||||||
|
registerUoTrigger({ ceiling: 'owner', audience: 'owner' })
|
||||||
|
addRule({ audience: 'owner' })
|
||||||
|
|
||||||
|
const out = await engine.dispatch(event({ ceiling: 'staff' }), T0)
|
||||||
|
assert.equal(out.enqueued, 0)
|
||||||
|
})
|
||||||
|
|
||||||
test('a rule for an unregistered trigger is dormant, not deleted and not an error', async () => {
|
test('a rule for an unregistered trigger is dormant, not deleted and not an error', async () => {
|
||||||
const rule = addRule({ trigger_id: 'uo.gone.away' })
|
const rule = addRule({ trigger_id: 'uo.gone.away' })
|
||||||
const listed = await rules.listAnnotated()
|
const listed = await rules.listAnnotated()
|
||||||
|
|||||||
@@ -26,8 +26,16 @@ afterEach(() => registries._reset())
|
|||||||
const MANIFEST_PATH = path.join(__dirname, '..', 'engagement-triggers.json')
|
const MANIFEST_PATH = path.join(__dirname, '..', 'engagement-triggers.json')
|
||||||
const serialize = (m) => `${JSON.stringify(m, null, 2)}\n`
|
const serialize = (m) => `${JSON.stringify(m, null, 2)}\n`
|
||||||
|
|
||||||
|
// **Normalised, like `routeManifest.test.js`'s `read()` one file along.** Under
|
||||||
|
// `core.autocrlf=true` git checks the committed LF blob out as CRLF, so a byte
|
||||||
|
// comparison fails on every Windows checkout while CI stays green — and it fails
|
||||||
|
// saying "the manifest is stale", which is the one thing it is not. The claim
|
||||||
|
// here is that the committed file describes the same declarations; a line ending
|
||||||
|
// is not a declaration.
|
||||||
|
const committedManifest = () => fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
|
||||||
|
|
||||||
test('the committed manifest matches the declarations in the tree', () => {
|
test('the committed manifest matches the declarations in the tree', () => {
|
||||||
const committed = fs.readFileSync(MANIFEST_PATH, 'utf8')
|
const committed = committedManifest()
|
||||||
assert.equal(
|
assert.equal(
|
||||||
serialize(build()),
|
serialize(build()),
|
||||||
committed,
|
committed,
|
||||||
|
|||||||
@@ -76,15 +76,50 @@ function register(owner, fn) {
|
|||||||
|
|
||||||
// ── Core's own declarations ────────────────────────────────────────────────
|
// ── Core's own declarations ────────────────────────────────────────────────
|
||||||
|
|
||||||
test('core registers its five triggers, and they are the five stream ids', () => {
|
test('every core STREAM is also a trigger, and one namespace holds both', () => {
|
||||||
|
// §7.2's one namespace, and what Events Phase 10 taught it. Until then the two
|
||||||
|
// sets were identical and the test asserted that; they are not identical any
|
||||||
|
// more and were never required to be. **A stream is a push toggle and a
|
||||||
|
// trigger is a payload contract**, and the rule is containment in one
|
||||||
|
// direction only: an id that can be pushed must have something to say, so
|
||||||
|
// every stream is a trigger — while a trigger that is not a stream is simply
|
||||||
|
// one nothing wakes a phone for, which is six of the seven event triggers.
|
||||||
|
//
|
||||||
|
// The direction matters. A STREAM with no trigger would be a push toggle for
|
||||||
|
// an event no rule can fire; a trigger with no stream is mail and an inbox row
|
||||||
|
// and no tickle, which is a complete and useful notification.
|
||||||
registries.registerCore()
|
registries.registerCore()
|
||||||
const triggerIds = registries.allTriggers().map((t) => t.id).sort()
|
const triggerIds = registries.allTriggers().map((t) => t.id).sort()
|
||||||
const streamIds = registries.allStreams().map((s) => s.id).sort()
|
const streamIds = registries.allStreams().map((s) => s.id).sort()
|
||||||
assert.deepEqual(triggerIds, streamIds)
|
assert.deepEqual(streamIds.filter((id) => !triggerIds.includes(id)), [])
|
||||||
assert.deepEqual(triggerIds, [
|
assert.deepEqual(triggerIds, [
|
||||||
|
'event.phase.changed', 'event.run.cancelled', 'event.run.completed',
|
||||||
|
'event.run.ending', 'event.run.failed', 'event.run.scheduled',
|
||||||
|
'event.run.started',
|
||||||
'news.post', 'team.announcement', 'team.forum.post',
|
'news.post', 'team.announcement', 'team.forum.post',
|
||||||
'team.leadership.changed', 'team.member.joined',
|
'team.leadership.changed', 'team.member.joined',
|
||||||
])
|
])
|
||||||
|
// The one event id that is BOTH (org lead, 2026-09-04). Push is the channel
|
||||||
|
// that says "now", and this is the only lifecycle moment worth waking a phone
|
||||||
|
// for. A rule naming `push` on any of the other six would enqueue a tickle
|
||||||
|
// nobody can subscribe to — the defect the live walk found.
|
||||||
|
assert.deepEqual(streamIds, [
|
||||||
|
'event.run.started',
|
||||||
|
'news.post', 'team.announcement', 'team.forum.post',
|
||||||
|
'team.leadership.changed', 'team.member.joined',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only a stream offers the push channel, which is why run.started is one', () => {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model')
|
||||||
|
registries.registerCore()
|
||||||
|
const byId = Object.fromEntries(prefs.catalog({ role: 'admin' }).map((i) => [i.id, i]))
|
||||||
|
assert.ok(byId['event.run.started'].channels.includes('push'))
|
||||||
|
assert.ok(!byId['event.run.completed'].channels.includes('push'))
|
||||||
|
// …and a trigger-only id still reaches a person on the two content channels.
|
||||||
|
assert.ok(byId['event.run.completed'].channels.includes('email'))
|
||||||
|
assert.ok(byId['event.run.completed'].channels.includes('inapp'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the four Team triggers ceiling at members — a private forum excerpt cannot be widened', () => {
|
test('the four Team triggers ceiling at members — a private forum excerpt cannot be widened', () => {
|
||||||
@@ -413,7 +448,8 @@ test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling
|
|||||||
registries.registerCore()
|
registries.registerCore()
|
||||||
const res = mockRes()
|
const res = mockRes()
|
||||||
ctrl.listTriggers({}, res)
|
ctrl.listTriggers({}, res)
|
||||||
assert.equal(res.body.triggers.length, 5)
|
// Five, plus Events Phase 10's seven.
|
||||||
|
assert.equal(res.body.triggers.length, 12)
|
||||||
const news = res.body.triggers.find((t) => t.id === 'news.post')
|
const news = res.body.triggers.find((t) => t.id === 'news.post')
|
||||||
assert.equal(news.owner, 'core')
|
assert.equal(news.owner, 'core')
|
||||||
assert.ok(news.variables.some((v) => v.name === 'title' && v.example))
|
assert.ok(news.variables.some((v) => v.name === 'title' && v.example))
|
||||||
|
|||||||
477
server/test/eventActionRegistry.test.js
Normal file
477
server/test/eventActionRegistry.test.js
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
// ── The event action registry (EVENTS.md §F, Phase 1) ──────────────────────
|
||||||
|
//
|
||||||
|
// Phase 1's acceptance criteria for the registry half, one test apiece:
|
||||||
|
//
|
||||||
|
// • core's three actions register on every boot and appear in the catalog
|
||||||
|
// • the catalog never carries a callable — no `perform`, `revert` or `cost`
|
||||||
|
// • a module registering an un-namespaced action fails, with the holder named
|
||||||
|
// • the closed sets are closed: an invented risk or reversibility is refused
|
||||||
|
// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION,
|
||||||
|
// not discovered at teardown when something has already been created
|
||||||
|
// • an action id and a trigger id are DIFFERENT namespaces, so one id may
|
||||||
|
// legitimately be both — the property the audience registry established and
|
||||||
|
// this one inherits
|
||||||
|
//
|
||||||
|
// Point the DB at a closed port BEFORE requiring anything: registries.js reaches
|
||||||
|
// utils/discordAnnounce, which reaches the pool at require time.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const registries = require('../src/modules/registries')
|
||||||
|
const coreEventActions = require('../src/config/coreEventActions')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
beforeEach(() => registries._reset())
|
||||||
|
afterEach(() => registries._reset())
|
||||||
|
|
||||||
|
const ok = (over = {}) => ({
|
||||||
|
id: 'demo.thing.do',
|
||||||
|
label: 'Do the thing',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'none',
|
||||||
|
perform: async () => ({ ok: true }),
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const register = (owner, entries) => {
|
||||||
|
const api = registries.stage(owner)
|
||||||
|
api.registerEventActions(entries)
|
||||||
|
registries.apply(api.staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('core registers its six actions on every boot', () => {
|
||||||
|
registries.registerCore()
|
||||||
|
const ids = registries.allEventActions().map((a) => a.id)
|
||||||
|
// `core.lease` joined the three in Phase 8, and it is the only one of the four
|
||||||
|
// that genuinely changes the world — which is why it is core's rather than each
|
||||||
|
// module's: §F puts the duration bound and the two-events-one-target conflict
|
||||||
|
// check on core's side of the seam, and a lease verb per module would be that
|
||||||
|
// bound re-implemented once per module and advisory everywhere.
|
||||||
|
//
|
||||||
|
// Phase 10 added the last two, and they are the integrations (§J):
|
||||||
|
// `core.announce.post` sends an ARTICLE through the announce legs rather than
|
||||||
|
// a line, and `core.results.publish` is what makes §F's "publish results"
|
||||||
|
// literal. Six is now the whole of what an event can do on a deployment with
|
||||||
|
// no game module installed at all.
|
||||||
|
assert.deepEqual(ids, [
|
||||||
|
'core.announce',
|
||||||
|
'core.wait',
|
||||||
|
'core.cue',
|
||||||
|
'core.lease',
|
||||||
|
'core.announce.post',
|
||||||
|
'core.results.publish',
|
||||||
|
])
|
||||||
|
assert.equal(ids.length, coreEventActions.ACTIONS.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the catalog carries no callable', () => {
|
||||||
|
registries.registerCore()
|
||||||
|
for (const action of registries.allEventActions()) {
|
||||||
|
assert.equal(action.perform, undefined, `${action.id} leaked perform`)
|
||||||
|
assert.equal(action.revert, undefined, `${action.id} leaked revert`)
|
||||||
|
assert.equal(action.reconcile, undefined, `${action.id} leaked reconcile`)
|
||||||
|
assert.equal(action.cost, undefined, `${action.id} leaked cost`)
|
||||||
|
}
|
||||||
|
// …and the runner's own lookup still has it, which is the half that makes the
|
||||||
|
// stripping a boundary rather than a deletion.
|
||||||
|
assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('core.announce refuses an unregistered leg terminally, and never claims success', async () => {
|
||||||
|
// Phase 1's version of this test asserted that all three core actions REFUSED,
|
||||||
|
// because none of them was wired yet. Phase 2 gave them real bodies, so what
|
||||||
|
// survives is the half that was never about the placeholder: `ok: true` on an
|
||||||
|
// action that did nothing is a recorded world change that did not occur.
|
||||||
|
//
|
||||||
|
// `core.announce` is the one that can still legitimately refuse. A leg nobody
|
||||||
|
// registers will not appear between two attempts a minute apart, so the answer
|
||||||
|
// is terminal rather than transient — a human has to fix it.
|
||||||
|
registries.registerCore()
|
||||||
|
const answer = await registries.eventAction('core.announce').perform({
|
||||||
|
params: { leg: 'nowhere', body: 'hello' },
|
||||||
|
})
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.retry, false)
|
||||||
|
assert.match(answer.error, /nowhere/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run validates and reports, but dispatches nothing', async () => {
|
||||||
|
// §I's dry run: `verify === true` means validate and report, change nothing.
|
||||||
|
// `core.announce` is the only core action with an outside effect to suppress.
|
||||||
|
//
|
||||||
|
// **The leg is resolved BEFORE `verify` is honoured, and that ordering is the
|
||||||
|
// point rather than an oversight.** A dry run exists to report what would
|
||||||
|
// happen, and "this step names a leg nobody registers" is the most useful thing
|
||||||
|
// it can find. Answering `ok: true` first would make the dry run pass on
|
||||||
|
// exactly the definition that cannot work.
|
||||||
|
registries.registerCore()
|
||||||
|
const announce = registries.eventAction('core.announce')
|
||||||
|
|
||||||
|
const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true })
|
||||||
|
assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist')
|
||||||
|
|
||||||
|
// A registered leg: reported good, and its transport never touched.
|
||||||
|
const leg = registries.announceLeg('discord')
|
||||||
|
const dispatch = leg.dispatch
|
||||||
|
let dispatched = 0
|
||||||
|
leg.dispatch = async () => {
|
||||||
|
dispatched += 1
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true })
|
||||||
|
assert.equal(good.ok, true)
|
||||||
|
assert.equal(dispatched, 0, 'a dry run sends nothing')
|
||||||
|
} finally {
|
||||||
|
leg.dispatch = dispatch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => {
|
||||||
|
// Both answer through ordinary envelope members, which is what lets the runner
|
||||||
|
// honour them without knowing what either action is. A `perform` that slept
|
||||||
|
// would hold its claim for the duration and turn a five-minute pause into a
|
||||||
|
// five-minute lease.
|
||||||
|
registries.registerCore()
|
||||||
|
assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), {
|
||||||
|
ok: true,
|
||||||
|
holdFor: 300,
|
||||||
|
})
|
||||||
|
assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), {
|
||||||
|
ok: true,
|
||||||
|
await: 'human',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action must be namespaced to its owner, and the holder is named', () => {
|
||||||
|
assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/)
|
||||||
|
|
||||||
|
register('demo', [ok()])
|
||||||
|
assert.throws(
|
||||||
|
() => register('rival', [ok({ id: 'demo.thing.do' })]),
|
||||||
|
/already registered by "demo"/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the same id twice in one batch is refused', () => {
|
||||||
|
assert.throws(() => register('demo', [ok(), ok()]), /registered twice/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('risk and reversibility are closed sets with no default', () => {
|
||||||
|
assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/)
|
||||||
|
assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/)
|
||||||
|
assert.throws(
|
||||||
|
() => register('demo', [ok({ reversible: undefined })]),
|
||||||
|
/needs a reversible class/,
|
||||||
|
)
|
||||||
|
assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reversible: 'ledger' without revert() is refused at registration", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => register('demo', [ok({ reversible: 'ledger' })]),
|
||||||
|
/is reversible: 'ledger' but has no revert\(\)/,
|
||||||
|
)
|
||||||
|
// And the mirror: a revert() nothing will ever call is a promise core does not
|
||||||
|
// keep, so it is refused just as loudly.
|
||||||
|
assert.throws(
|
||||||
|
() => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]),
|
||||||
|
/declares revert\(\) but is reversible: 'none'/,
|
||||||
|
)
|
||||||
|
register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })])
|
||||||
|
assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('perform() is required and cost must be a function', () => {
|
||||||
|
assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/)
|
||||||
|
assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every param needs a type and an example', () => {
|
||||||
|
const withParams = (params) => ok({ params })
|
||||||
|
assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/)
|
||||||
|
assert.throws(
|
||||||
|
() => register('demo', [withParams([{ name: 'x', type: 'int' }])]),
|
||||||
|
/needs an example/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]),
|
||||||
|
/bad param name/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
register('demo', [
|
||||||
|
withParams([
|
||||||
|
{ name: 'x', type: 'int', example: 1 },
|
||||||
|
{ name: 'x', type: 'int', example: 2 },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
/declared twice/,
|
||||||
|
)
|
||||||
|
register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])])
|
||||||
|
const [param] = registries.eventAction('demo.thing.do').params
|
||||||
|
assert.deepEqual(param, {
|
||||||
|
name: 'x',
|
||||||
|
type: 'int',
|
||||||
|
required: false,
|
||||||
|
example: 12,
|
||||||
|
source: 'demo.options.x',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('budgetMs defaults, and is bounded', () => {
|
||||||
|
register('demo', [ok()])
|
||||||
|
assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS)
|
||||||
|
registries._reset()
|
||||||
|
assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/)
|
||||||
|
assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('actions and triggers are different namespaces, so one id may be both', () => {
|
||||||
|
// The property §F states and the audience registry established first. A verb
|
||||||
|
// called `demo.raid.start` and an event called `demo.raid.start` are two
|
||||||
|
// unrelated declarations, and forbidding the pair would forbid the most
|
||||||
|
// natural names a module will ever want.
|
||||||
|
const api = registries.stage('demo')
|
||||||
|
api.registerEventTriggers([
|
||||||
|
{ id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' },
|
||||||
|
])
|
||||||
|
api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })])
|
||||||
|
registries.apply(api.staged)
|
||||||
|
|
||||||
|
assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started')
|
||||||
|
assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a whole batch is refused or taken, never half', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]),
|
||||||
|
/needs a risk class/,
|
||||||
|
)
|
||||||
|
// The shape check throws at the CALL, before anything is staged, so nothing
|
||||||
|
// from the batch is visible.
|
||||||
|
assert.equal(registries.eventAction('demo.thing.do'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('_reset() hands the process back', () => {
|
||||||
|
registries.registerCore()
|
||||||
|
assert.equal(registries.allEventActions().length, 6)
|
||||||
|
registries._reset()
|
||||||
|
assert.equal(registries.allEventActions().length, 0)
|
||||||
|
assert.equal(registries.isEventAction('core.wait'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Budgets, leases and option sources (§F, Phase 7) ───────────────────────
|
||||||
|
//
|
||||||
|
// The three declarations that arrive WITH the module-facing seam. What is worth
|
||||||
|
// a test here is the same thing that was worth one for actions: the closed sets
|
||||||
|
// are closed, the required members are required, and each id space is its own.
|
||||||
|
// The seam being reachable by a module at all is `eventModuleContract.test.js`,
|
||||||
|
// against a real loader; these are the shape rules, at the call.
|
||||||
|
|
||||||
|
const registerBudgets = (owner, entries) => {
|
||||||
|
const api = registries.stage(owner)
|
||||||
|
api.registerEventBudgets(entries)
|
||||||
|
registries.apply(api.staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerLeases = (owner, entries) => {
|
||||||
|
const api = registries.stage(owner)
|
||||||
|
api.registerEventLeases(entries)
|
||||||
|
registries.apply(api.staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lease = (over = {}) => ({
|
||||||
|
id: 'demo.rate.gain',
|
||||||
|
label: 'Gain rate',
|
||||||
|
type: 'float',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
maxDurationMs: 3_600_000,
|
||||||
|
read: async () => ({ ok: true, value: 1 }),
|
||||||
|
apply: async () => ({ ok: true }),
|
||||||
|
restore: async () => ({ ok: true }),
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a budget needs a unit, because a number on a cap box is ambiguous without one', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps' }]),
|
||||||
|
/has no unit/,
|
||||||
|
)
|
||||||
|
// Open vocabulary, deliberately: core never interprets a unit, it renders it.
|
||||||
|
// Closing the set would make "kilometres" a MODULE_API bump for a noun core
|
||||||
|
// does not read.
|
||||||
|
registerBudgets('demo', [{ id: 'demo.road', label: 'Road laid', unit: 'kilometres' }])
|
||||||
|
assert.equal(registries.eventBudget('demo.road').unit, 'kilometres')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a budget and an action are different id spaces, so one name may be both', () => {
|
||||||
|
// §F says it in one line — an action names a VERB and a budget names a
|
||||||
|
// RESOURCE — and this is the pair every module will actually write. Reading a
|
||||||
|
// collision here would forbid the most natural set of names there is.
|
||||||
|
const api = registries.stage('demo')
|
||||||
|
api.registerEventBudgets([{ id: 'demo.creatures', label: 'Creatures', unit: 'count' }])
|
||||||
|
api.registerEventActions([ok({ id: 'demo.creatures' })])
|
||||||
|
registries.apply(api.staged)
|
||||||
|
assert.equal(registries.eventBudget('demo.creatures').label, 'Creatures')
|
||||||
|
assert.equal(registries.eventAction('demo.creatures').label, 'Do the thing')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a budget is claimed once, and the second claim names who holds it', () => {
|
||||||
|
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
||||||
|
assert.throws(
|
||||||
|
() => registerBudgets('other', [{ id: 'demo.wisps', label: 'Theirs', unit: 'count' }]),
|
||||||
|
/already registered by "demo"/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lease declares all three callables, and restore is not optional', () => {
|
||||||
|
// `read` could stand in for `restore`, and that is exactly why it may not:
|
||||||
|
// `read` answers "what is it now" and `restore` answers "put this back, and
|
||||||
|
// tell me if someone else has moved it". The drift check is the one thing a
|
||||||
|
// module must not be allowed to skip — a restore that writes blindly silently
|
||||||
|
// reverts an operator's manual fix.
|
||||||
|
for (const missing of ['read', 'apply', 'restore']) {
|
||||||
|
assert.throws(
|
||||||
|
() => registerLeases('demo', [lease({ [missing]: undefined })]),
|
||||||
|
new RegExp(`has no ${missing}\(\)`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a numeric lease is bounded, and an unbounded one is refused', () => {
|
||||||
|
// A lease on a rate multiplier with no range is an operator one keystroke away
|
||||||
|
// from setting a shard's skill gain to 5000 — and unlike a cap, a bad lease
|
||||||
|
// value is in force the moment it is applied.
|
||||||
|
assert.throws(() => registerLeases('demo', [lease({ min: undefined })]), /numeric min and max/)
|
||||||
|
assert.throws(() => registerLeases('demo', [lease({ min: 9, max: 2 })]), /min 9 above max 2/)
|
||||||
|
assert.throws(
|
||||||
|
() => registerLeases('demo', [lease({ type: 'int', min: 0.5, max: 5 })]),
|
||||||
|
/whole-number min and max/,
|
||||||
|
)
|
||||||
|
// A bool holds no range and is not asked for one.
|
||||||
|
registerLeases('demo', [lease({ id: 'demo.seasonal', type: 'bool', min: undefined, max: undefined })])
|
||||||
|
assert.equal(registries.eventLease('demo.seasonal').min, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lease may not be held longer than core is willing to promise', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => registerLeases('demo', [lease({ maxDurationMs: registries.MAX_LEASE_MS + 1 })]),
|
||||||
|
/maxDurationMs must be 1\.\./,
|
||||||
|
)
|
||||||
|
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 0 })]), /maxDurationMs must be/)
|
||||||
|
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 1.5 })]), /maxDurationMs must be/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown lease type is refused, because core validates operator input against it', () => {
|
||||||
|
assert.throws(() => registerLeases('demo', [lease({ type: 'colour' })]), /needs a type, one of/)
|
||||||
|
assert.deepEqual(registries.LEASE_TYPES, ['int', 'float', 'bool', 'string'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an option source needs a resolver, and core registers one of its own', () => {
|
||||||
|
const api = registries.stage('demo')
|
||||||
|
assert.throws(
|
||||||
|
() => api.registerEventOptionSources([{ id: 'demo.options.hues', label: 'Hues' }]),
|
||||||
|
/has no resolve\(\)/,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Core through the same door (Phase 7): `core.announce`'s `leg` param names a
|
||||||
|
// source, and the announce legs are already a registry with labels in them.
|
||||||
|
registries.registerCore()
|
||||||
|
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), [
|
||||||
|
'core.options.legs',
|
||||||
|
// Phase 8's, and it is the same argument one phase on: `core.lease`'s `lease`
|
||||||
|
// param would otherwise be a free-text box whose typo is caught at dispatch,
|
||||||
|
// mid-run — and the leases are already a registry with labels in them.
|
||||||
|
'core.options.leases',
|
||||||
|
// Phase 10's, and the first core source that reaches a table. It is allowed
|
||||||
|
// to because of WHEN a source resolves: on its own request, on a booted
|
||||||
|
// server, rather than at `register()` time under a dead pool.
|
||||||
|
'core.options.posts',
|
||||||
|
])
|
||||||
|
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
|
||||||
|
assert.equal(leg.source, 'core.options.legs')
|
||||||
|
const which = registries.eventAction('core.lease').params.find((p) => p.name === 'lease')
|
||||||
|
assert.equal(which.source, 'core.options.leases')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── `reconcile`, the one member Phase 8 added to the action shape ──────────
|
||||||
|
//
|
||||||
|
// Optional where `revert` is required, and the asymmetry is the design: a module
|
||||||
|
// that cannot say what the game still has is not broken — core keeps believing
|
||||||
|
// its own ledger, which is the behaviour before this phase — whereas a module
|
||||||
|
// that created something and cannot undo it has made a promise core has no way
|
||||||
|
// to keep.
|
||||||
|
|
||||||
|
test('reconcile is optional, must be a function, and only on an action that ledgers', () => {
|
||||||
|
const ledgering = {
|
||||||
|
id: 'demo.spawn',
|
||||||
|
label: 'Spawn',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
perform: async () => ({ ok: true }),
|
||||||
|
revert: async () => ({ ok: true }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absent is legal, and it lands as an explicit null rather than as a missing
|
||||||
|
// key — the same shape `revert` and `cost` take, so the catalog's strip list
|
||||||
|
// and the sweep's `typeof` check both have something to look at.
|
||||||
|
register('demo', [ledgering])
|
||||||
|
assert.equal(registries.eventAction('demo.spawn').reconcile, null)
|
||||||
|
registries._reset()
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => register('demo', [{ ...ledgering, reconcile: 'yes please' }]),
|
||||||
|
/reconcile must be a function/,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mirror check `revert` already has. An action that ledgers nothing has no
|
||||||
|
// rows for core to ask about, so a `reconcile` on one is an author who believes
|
||||||
|
// something is being tracked and a sweep that will never call it.
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
register('demo', [
|
||||||
|
{
|
||||||
|
id: 'demo.shout',
|
||||||
|
label: 'Shout',
|
||||||
|
risk: 'notify',
|
||||||
|
reversible: 'none',
|
||||||
|
perform: async () => ({ ok: true }),
|
||||||
|
reconcile: async () => ({ ok: true, inForce: [] }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
/declares reconcile\(\) but is reversible: 'none' and ledgers nothing/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an override action may reconcile, because a lease is ledgered too', () => {
|
||||||
|
register('demo', [
|
||||||
|
{
|
||||||
|
id: 'demo.borrow',
|
||||||
|
label: 'Borrow',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'override',
|
||||||
|
perform: async () => ({ ok: true }),
|
||||||
|
reconcile: async () => ({ ok: true, inForce: [] }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
assert.equal(typeof registries.eventAction('demo.borrow').reconcile, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('_reset() hands back the three new registries too', () => {
|
||||||
|
registries.registerCore()
|
||||||
|
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
||||||
|
registerLeases('demo', [lease()])
|
||||||
|
assert.equal(registries.allEventBudgets().length, 1)
|
||||||
|
registries._reset()
|
||||||
|
assert.deepEqual(registries.allEventBudgets(), [])
|
||||||
|
assert.deepEqual(registries.allEventLeases(), [])
|
||||||
|
assert.deepEqual(registries.allEventOptionSources(), [])
|
||||||
|
})
|
||||||
369
server/test/eventAnnounce.test.js
Normal file
369
server/test/eventAnnounce.test.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
// ── The lifecycle announcements (EVENTS_PLAN.md Phase 10) ──────────────────
|
||||||
|
//
|
||||||
|
// §J's "Events owns none of the delivery", made testable. What this file
|
||||||
|
// asserts is that `events/announce.js` says the right thing and then stops —
|
||||||
|
// nothing here knows about email, subscribers, templates or cooldowns, and the
|
||||||
|
// evidence for that is that every test below stubs `engagementEmit.emit` and
|
||||||
|
// reads what was handed to it.
|
||||||
|
//
|
||||||
|
// Three properties, and each is a thing that would be silent if it broke:
|
||||||
|
//
|
||||||
|
// • **a rehearsal narrows the ceiling** (§I). The same triggers fire, so the
|
||||||
|
// announce steps are genuinely rehearsed — and `ceiling: 'staff'` on the
|
||||||
|
// envelope is what stops a rehearsal of a published event mailing every
|
||||||
|
// subscriber it. This is the one property in the phase with a blast radius.
|
||||||
|
// • **the cooldown subject is the RUN.** Keyed on the user, `phase.changed`
|
||||||
|
// would mean "at most one phase of at most one event an hour" and would
|
||||||
|
// silently swallow the second wave of an invasion.
|
||||||
|
// • **a read that fails does not fail the run.** Every caller is a transition
|
||||||
|
// in the runner, and a run must not fail to start because the row that says
|
||||||
|
// what it is called could not be read.
|
||||||
|
//
|
||||||
|
// The trigger declarations get their own assertions here rather than in
|
||||||
|
// `engagementTriggers.test.js`, because what is being checked is not that the
|
||||||
|
// registry accepts them — it does, for anything well-formed — but the two
|
||||||
|
// EVENTS.md decisions they encode: which ceiling each sits at, and that the six
|
||||||
|
// public ones declare no `url` variable while there is no public page to point
|
||||||
|
// at (the `news.post` mistake, not repeated).
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const announce = require('../src/events/announce')
|
||||||
|
const engagementEmit = require('../src/utils/engagementEmit')
|
||||||
|
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||||
|
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||||
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
|
const { TRIGGERS } = require('../src/config/coreTriggers')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const DEFINITION = {
|
||||||
|
id: 3,
|
||||||
|
title: 'The Yew Invasion',
|
||||||
|
slug: 'the-yew-invasion',
|
||||||
|
summary: 'Orcish warbands are massing north of Yew.',
|
||||||
|
series_name: 'The Yew Campaign',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
// Both are load-bearing for `eventUrl` (Phase 14a): an event with no public
|
||||||
|
// page gets no link. They were absent from this fixture, which meant the url
|
||||||
|
// was undefined in every test here and the new code was exercised by none of
|
||||||
|
// them.
|
||||||
|
state: 'ready',
|
||||||
|
listed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUN = {
|
||||||
|
id: 3692,
|
||||||
|
definition_id: 3,
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
scheduled_for: new Date('2026-09-13T00:00:00.000Z'),
|
||||||
|
started_at: new Date('2026-09-13T00:00:14.000Z'),
|
||||||
|
current_phase: 'assault',
|
||||||
|
rehearsal: 0,
|
||||||
|
last_error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let emitted
|
||||||
|
let lines
|
||||||
|
const originals = {
|
||||||
|
emit: engagementEmit.emit,
|
||||||
|
getById: definitionsDb.getById,
|
||||||
|
count: participantsDb.countForRun,
|
||||||
|
write: logDb.write,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
emitted = []
|
||||||
|
lines = []
|
||||||
|
engagementEmit.emit = (owner, triggerId, envelope) => {
|
||||||
|
emitted.push({ owner, triggerId, envelope })
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
definitionsDb.getById = async (id) => (id === DEFINITION.id ? { ...DEFINITION } : null)
|
||||||
|
participantsDb.countForRun = async () => 12
|
||||||
|
logDb.write = async (line) => {
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
engagementEmit.emit = originals.emit
|
||||||
|
definitionsDb.getById = originals.getById
|
||||||
|
participantsDb.countForRun = originals.count
|
||||||
|
logDb.write = originals.write
|
||||||
|
})
|
||||||
|
|
||||||
|
const only = () => {
|
||||||
|
assert.equal(emitted.length, 1, `expected one emit, got ${emitted.length}`)
|
||||||
|
return emitted[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The shared envelope ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('core emits as core, keyed on the run, with the definition\'s own facts', async () => {
|
||||||
|
await announce.runStarted(RUN)
|
||||||
|
const { owner, triggerId, envelope } = only()
|
||||||
|
|
||||||
|
assert.equal(owner, 'core')
|
||||||
|
assert.equal(triggerId, 'event.run.started')
|
||||||
|
// Keyed on the RUN. Two occurrences of a weekly event are two subjects, so
|
||||||
|
// last week's mail does not throttle this week's.
|
||||||
|
assert.equal(envelope.subject, '3692')
|
||||||
|
assert.equal(envelope.scopeKey, 'event:3692')
|
||||||
|
assert.equal(envelope.data.runId, '3692')
|
||||||
|
assert.equal(envelope.data.title, 'The Yew Invasion')
|
||||||
|
assert.equal(envelope.data.summary, DEFINITION.summary)
|
||||||
|
assert.equal(envelope.data.seriesName, 'The Yew Campaign')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a real run carries NO ceiling, so the declaration\'s own is what bounds it', async () => {
|
||||||
|
await announce.runStarted(RUN)
|
||||||
|
assert.equal(only().envelope.ceiling, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a REHEARSAL fires the same trigger and ceilings it at staff', async () => {
|
||||||
|
// §I: "run for real with announcements ceilinged to `staff`". Emitting
|
||||||
|
// nothing would be a rehearsal of everything except the announcements.
|
||||||
|
await announce.runStarted({ ...RUN, rehearsal: 1 })
|
||||||
|
const { triggerId, envelope } = only()
|
||||||
|
assert.equal(triggerId, 'event.run.started')
|
||||||
|
assert.equal(envelope.ceiling, 'staff')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the run log says what was announced and, on a rehearsal, why it was bounded', async () => {
|
||||||
|
await announce.runStarted({ ...RUN, rehearsal: 1 })
|
||||||
|
const line = lines.find((l) => l.kind === 'announcement.emitted')
|
||||||
|
assert.equal(line.detail.trigger, 'event.run.started')
|
||||||
|
assert.equal(line.detail.ceiling, 'staff')
|
||||||
|
assert.equal(line.detail.because, 'rehearsal')
|
||||||
|
// How many people were told is the engagement engine's decision and its own
|
||||||
|
// log line. A run log that reported a number would be claiming a decision it
|
||||||
|
// does not make.
|
||||||
|
assert.equal(line.detail.recipients, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a definition that has gone away announces nothing and does not throw', async () => {
|
||||||
|
await announce.runStarted({ ...RUN, definition_id: 999 })
|
||||||
|
assert.equal(emitted.length, 0)
|
||||||
|
assert.equal(lines.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a read that throws is swallowed — a run must not fail to start over an announcement', async () => {
|
||||||
|
definitionsDb.getById = async () => {
|
||||||
|
throw new Error('pool timeout')
|
||||||
|
}
|
||||||
|
await announce.runStarted(RUN)
|
||||||
|
assert.equal(emitted.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Per-moment payloads ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('phase.changed counts phases from one, for a human reading a sentence', async () => {
|
||||||
|
await announce.phaseChanged(RUN, { phase: 'assault', label: 'The assault', index: 1, count: 4 })
|
||||||
|
const { data } = only().envelope
|
||||||
|
assert.equal(data.phase, 'assault')
|
||||||
|
assert.equal(data.phaseLabel, 'The assault')
|
||||||
|
assert.equal(data.phaseIndex, 2)
|
||||||
|
assert.equal(data.phaseCount, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('phase.changed falls back to the key when a phase has no label', async () => {
|
||||||
|
await announce.phaseChanged(RUN, { phase: 'assault', label: null, index: 0, count: 2 })
|
||||||
|
assert.equal(only().envelope.data.phaseLabel, 'assault')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('run.completed counts the participants and the minutes it took', async () => {
|
||||||
|
await announce.runCompleted(RUN, new Date('2026-09-13T01:35:14.000Z'))
|
||||||
|
const { data } = only().envelope
|
||||||
|
assert.equal(data.participantCount, 12)
|
||||||
|
assert.equal(data.durationMinutes, 95)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a participant count that cannot be read is zero, not missing', async () => {
|
||||||
|
// The variable is declared `required`, so it has to be a number — and zero is
|
||||||
|
// also the honest answer for the far more common case of a run nothing
|
||||||
|
// collected for.
|
||||||
|
participantsDb.countForRun = async () => {
|
||||||
|
throw new Error('table is gone')
|
||||||
|
}
|
||||||
|
await announce.runCompleted(RUN, new Date('2026-09-13T00:30:14.000Z'))
|
||||||
|
assert.equal(only().envelope.data.participantCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run that never started reports no duration rather than a negative one', async () => {
|
||||||
|
await announce.runCompleted({ ...RUN, started_at: null }, new Date('2026-09-13T01:00:00.000Z'))
|
||||||
|
assert.equal(only().envelope.data.durationMinutes, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('run.cancelled carries the operator\'s reason, and omits it when none was given', async () => {
|
||||||
|
await announce.runCancelled(RUN, 'The shard is down for an emergency patch.')
|
||||||
|
assert.equal(only().envelope.data.reason, 'The shard is down for an emergency patch.')
|
||||||
|
emitted = []
|
||||||
|
await announce.runCancelled(RUN, null)
|
||||||
|
assert.equal(only().envelope.data.reason, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// `run.failed` alone gets no public page, and the DECLARATION is what enforces
|
||||||
|
// that rather than anything here: `baseFor` assembles `eventUrl` for every
|
||||||
|
// trigger and the seam drops the keys a trigger does not declare. The test above
|
||||||
|
// that asserts run.failed's url variables are exactly `['runUrl']` is therefore
|
||||||
|
// the one that proves it — an assertion on this envelope would be reading the
|
||||||
|
// wrong layer, because the filtering has not happened yet at this point.
|
||||||
|
test('run.failed links the run console — the one destination that exists today', async () => {
|
||||||
|
await announce.runFailed(RUN, 'sidecar responded 503')
|
||||||
|
const { data } = only().envelope
|
||||||
|
assert.equal(data.error, 'sidecar responded 503')
|
||||||
|
assert.equal(data.phase, 'assault')
|
||||||
|
assert.equal(data.runUrl, '/admin/events/runs/3692')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every public emit carries the page for THIS occurrence', async () => {
|
||||||
|
await announce.runStarted(RUN)
|
||||||
|
// The slug is the definition's and the run is in the query string. Without
|
||||||
|
// `?run=` a mail about last Friday's occurrence would open next Friday's.
|
||||||
|
assert.equal(only().envelope.data.eventUrl, '/site/events/the-yew-invasion?run=3692')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an UNLISTED event announces with no link rather than a link that 404s', async () => {
|
||||||
|
// `eventUrl` is declared optional exactly so `email.button` can drop itself.
|
||||||
|
// A path here would render as a dead button in every mail — worse than none,
|
||||||
|
// because it advertises a link the reader cannot follow. `news.post` paid for
|
||||||
|
// that once already.
|
||||||
|
definitionsDb.getById = async () => ({ ...DEFINITION, listed: false })
|
||||||
|
await announce.runStarted(RUN)
|
||||||
|
assert.equal(only().envelope.data.eventUrl, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a definition that is not yet `ready` has no page either', async () => {
|
||||||
|
definitionsDb.getById = async () => ({ ...DEFINITION, state: 'draft' })
|
||||||
|
await announce.runStarted(RUN)
|
||||||
|
assert.equal(only().envelope.data.eventUrl, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('run.failed falls back to the run\'s own last error', async () => {
|
||||||
|
await announce.runFailed({ ...RUN, last_error: 'the pinned version has no phases' }, null)
|
||||||
|
assert.equal(only().envelope.data.error, 'the pinned version has no phases')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── startsAtLabel: the presentational fragment ─────────────────────────────
|
||||||
|
|
||||||
|
test('the start time is written out in the SHARD\'s zone, not the server\'s', () => {
|
||||||
|
// Midnight UTC on the 13th is 8pm on the 12th in New York, and the whole point
|
||||||
|
// of the label is that a reader sees the shard's evening.
|
||||||
|
const label = announce.startsAtLabel(new Date('2026-09-13T00:00:00Z'), 'America/New_York')
|
||||||
|
assert.match(label, /^Saturday 12 September at 8:00 pm \(America\/New_York\)$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('midnight reads as 12:00 am and never as 00:00', () => {
|
||||||
|
// The hour cycle is set explicitly. Left to the en-GB locale this would render
|
||||||
|
// "00:00" while the schedule editor beside it writes "12:00 AM" — one event,
|
||||||
|
// two spellings of the same instant.
|
||||||
|
//
|
||||||
|
// This assertion only has teeth on the Node the image ships (20), where
|
||||||
|
// `hour12: true` resolves to h11 and midnight reads "0:00 am". On Node 22+ it
|
||||||
|
// passes either way — so a green run on a dev machine is not evidence, and CI
|
||||||
|
// is what actually holds this line. See the note beside `hourCycle` in
|
||||||
|
// events/announce.js.
|
||||||
|
assert.match(announce.startsAtLabel(new Date('2026-09-13T04:00:00Z'), 'America/New_York'), /12:00 am/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a bad zone or a bad instant answers nothing rather than throwing', () => {
|
||||||
|
// The variable is optional and its template block is a single token, so an
|
||||||
|
// absent label renders as nothing at all rather than as a broken line.
|
||||||
|
assert.equal(announce.startsAtLabel(new Date('2026-09-13T00:00:00Z'), 'Middle/Earth'), undefined)
|
||||||
|
assert.equal(announce.startsAtLabel('not a date', 'UTC'), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the label rides on the two triggers that have a start time', async () => {
|
||||||
|
await announce.runScheduled(RUN)
|
||||||
|
assert.match(only().envelope.data.startsAtLabel, /8:00 pm \(America\/New_York\)/)
|
||||||
|
emitted = []
|
||||||
|
await announce.runEnding(RUN)
|
||||||
|
assert.equal(only().envelope.data.startsAtLabel, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The declarations themselves ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const eventTriggers = () => TRIGGERS.filter((t) => t.id.startsWith('event.'))
|
||||||
|
|
||||||
|
test('seven event triggers, and every one is keyed on the run', () => {
|
||||||
|
const ids = eventTriggers().map((t) => t.id)
|
||||||
|
assert.deepEqual(ids, [
|
||||||
|
'event.run.scheduled',
|
||||||
|
'event.run.started',
|
||||||
|
'event.phase.changed',
|
||||||
|
'event.run.ending',
|
||||||
|
'event.run.completed',
|
||||||
|
'event.run.cancelled',
|
||||||
|
'event.run.failed',
|
||||||
|
])
|
||||||
|
for (const t of eventTriggers()) {
|
||||||
|
assert.equal(t.subjectKey, 'runId', `${t.id} must key its cooldown on the run`)
|
||||||
|
assert.ok(
|
||||||
|
t.variables.some((v) => v.name === 'runId' && v.required),
|
||||||
|
`${t.id} declares subjectKey runId, so runId must be a required variable`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('six are ceilinged authenticated; run.failed is admin on both halves', () => {
|
||||||
|
for (const t of eventTriggers()) {
|
||||||
|
if (t.id === 'event.run.failed') {
|
||||||
|
// A failure names the deployment's own broken machinery. There is no
|
||||||
|
// widening of this that is not a disclosure, so the CEILING says so.
|
||||||
|
assert.equal(t.ceiling, 'admin')
|
||||||
|
assert.equal(t.audience, 'admin')
|
||||||
|
} else {
|
||||||
|
assert.equal(t.ceiling, 'authenticated', `${t.id}`)
|
||||||
|
assert.equal(t.audience, 'subscribers', `${t.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every public event trigger points at the page Phase 14a built, and run.failed at the console', () => {
|
||||||
|
// The inverse of what this asserted from Phase 10 until Phase 14a, and the
|
||||||
|
// inversion is the point: `news.post` shipped an example naming `/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 was withheld until there was a page,
|
||||||
|
// and it arrived with it.
|
||||||
|
for (const t of eventTriggers()) {
|
||||||
|
const urls = t.variables.filter((v) => v.type === 'url')
|
||||||
|
if (t.id === 'event.run.failed') {
|
||||||
|
// No `eventUrl` here, deliberately: an admin reading that the machinery
|
||||||
|
// broke wants the steps and the errors, not the storyline.
|
||||||
|
assert.deepEqual(urls.map((v) => v.name), ['runUrl'])
|
||||||
|
assert.match(urls[0].example, /^\/admin\/events\/runs\//)
|
||||||
|
} else {
|
||||||
|
assert.deepEqual(urls.map((v) => v.name), ['eventUrl'], `${t.id}`)
|
||||||
|
// The example has to carry `?run=`, because that is what makes a link in a
|
||||||
|
// mail about last Friday open last Friday rather than next Friday.
|
||||||
|
assert.match(urls[0].example, /^\/site\/events\/[^?]+\?run=/, `${t.id}`)
|
||||||
|
// Optional, so `email.button` drops itself rather than rendering an inert
|
||||||
|
// grey label when the event is not public and there is no page.
|
||||||
|
assert.equal(urls[0].required, false, `${t.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the six public triggers are at version 2 — the url variable is a declaration change', () => {
|
||||||
|
// A variable added to a declaration is a version bump, not a correction: a rule
|
||||||
|
// written against version 1 was written against a payload with no link in it.
|
||||||
|
// `run.failed` gained nothing and stays where it was.
|
||||||
|
for (const t of eventTriggers()) {
|
||||||
|
assert.equal(t.version, t.id === 'event.run.failed' ? 1 : 2, `${t.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every declared variable carries an example, which is what the editor previews with', () => {
|
||||||
|
for (const t of eventTriggers()) {
|
||||||
|
for (const v of t.variables) {
|
||||||
|
assert.notEqual(v.example, undefined, `${t.id}.${v.name} needs an example`)
|
||||||
|
assert.ok(v.description, `${t.id}.${v.name} needs a description`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
508
server/test/eventAuthorize.test.js
Normal file
508
server/test/eventAuthorize.test.js
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
// ── mayInvoke: the whole authorisation decision (EVENTS_PLAN.md Phase 6) ───
|
||||||
|
//
|
||||||
|
// §K asks for four layers behind ONE function — role, enablement, cap, and the
|
||||||
|
// shard's own switch — and says why: *"it is what makes an EM-style delegation
|
||||||
|
// model a later option rather than a redesign"*. This file is that function's
|
||||||
|
// contract, and it is worth stating what each test is actually protecting,
|
||||||
|
// because three of them protect a decision rather than a mechanism.
|
||||||
|
//
|
||||||
|
// • **The default-off line falls between `inspect` and `change`** (org lead,
|
||||||
|
// 2026-09-03). §K's sentence read literally would have shipped `core.wait`
|
||||||
|
// disabled — it is `risk: 'inspect'` — so every published event that waits
|
||||||
|
// would break on a fresh deployment. The same line is the role floor.
|
||||||
|
// • **The tightest cap wins.** `event_action_settings.caps` is per action while
|
||||||
|
// `event_run_budget` is one row per dimension, so two actions spending
|
||||||
|
// `uo.creatures` must agree on one number.
|
||||||
|
// • **An unpriceable action is refused, not free.** A `cost()` that throws is
|
||||||
|
// an action whose own accounting is broken, and reading that as zero would
|
||||||
|
// make the broken one the only one nothing bounds.
|
||||||
|
//
|
||||||
|
// The registry is the REAL one, staged and applied the way a module does it, for
|
||||||
|
// `eventRunner.test.js`'s reason: an action that would not register is not one
|
||||||
|
// this function has to survive.
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const registries = require('../src/modules/registries')
|
||||||
|
const authorize = require('../src/events/authorize')
|
||||||
|
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||||
|
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const originals = { settings: { ...settingsDb }, budget: { ...budgetDb } }
|
||||||
|
|
||||||
|
let store
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registries._reset()
|
||||||
|
store = { settings: new Map(), budget: new Map() }
|
||||||
|
|
||||||
|
settingsDb.get = async (id) => store.settings.get(id) || null
|
||||||
|
settingsDb.byIds = async (ids) =>
|
||||||
|
new Map([...new Set(ids || [])].filter((i) => store.settings.has(i)).map((i) => [i, store.settings.get(i)]))
|
||||||
|
budgetDb.forRun = async (runId) =>
|
||||||
|
[...store.budget.values()].filter((b) => Number(b.run_id) === Number(runId))
|
||||||
|
budgetDb.spend = async (runId, dimension, amount) => {
|
||||||
|
if (!(amount > 0)) return true
|
||||||
|
const row = store.budget.get(`${runId}:${dimension}`)
|
||||||
|
if (!row) return false
|
||||||
|
if (row.cap !== null && row.consumed + amount > row.cap) return false
|
||||||
|
row.consumed += amount
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
budgetDb.refund = async (runId, dimension, amount) => {
|
||||||
|
const row = store.budget.get(`${runId}:${dimension}`)
|
||||||
|
if (row && amount > 0) row.consumed = Math.max(row.consumed - amount, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(settingsDb, originals.settings)
|
||||||
|
Object.assign(budgetDb, originals.budget)
|
||||||
|
registries._reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare the budget dimensions a batch of actions prices.
|
||||||
|
*
|
||||||
|
* §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
|
||||||
|
* is refused BEFORE any cap arithmetic runs. Without this, every cap test below
|
||||||
|
* would pass for the WRONG reason — refused by the layer above the one under
|
||||||
|
* test. Discovered the way the switchboard discovers them, by pricing the
|
||||||
|
* action's own declared examples, so a test never has to keep a second list of
|
||||||
|
* its dimensions in step with its `cost()`.
|
||||||
|
*
|
||||||
|
* Written out here rather than borrowed from `authorize.dimensionsOf` so this
|
||||||
|
* file keeps stubbing exactly what it means to stub. The undeclared case is not
|
||||||
|
* an omission; it has its own test.
|
||||||
|
*/
|
||||||
|
const declaredBudgets = (entries, owner) => {
|
||||||
|
const dimensions = new Set()
|
||||||
|
for (const e of entries) {
|
||||||
|
if (typeof e.cost !== 'function') continue
|
||||||
|
const params = {}
|
||||||
|
for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
|
||||||
|
let priced
|
||||||
|
try {
|
||||||
|
priced = e.cost(params)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const d of Object.keys(priced || {})) dimensions.add(d)
|
||||||
|
}
|
||||||
|
return [...dimensions]
|
||||||
|
.filter((id) => id.startsWith(`${owner}.`))
|
||||||
|
.map((id) => ({ id, label: id, unit: 'count' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = (entries, { owner = 'test', budgets = true } = {}) => {
|
||||||
|
const api = registries.stage(owner)
|
||||||
|
api.registerEventActions(entries)
|
||||||
|
if (budgets) api.registerEventBudgets(declaredBudgets(entries, owner))
|
||||||
|
registries.apply(api.staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = (id, over = {}) => ({
|
||||||
|
id,
|
||||||
|
label: over.label || id,
|
||||||
|
risk: over.risk || 'notify',
|
||||||
|
reversible: over.reversible || 'none',
|
||||||
|
params: over.params || [],
|
||||||
|
...(over.cost ? { cost: over.cost } : {}),
|
||||||
|
async perform() {
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const setSetting = (id, enabled, caps = {}) =>
|
||||||
|
store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
|
||||||
|
|
||||||
|
const setBudget = (runId, dimension, { consumed = 0, cap = null } = {}) =>
|
||||||
|
store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap })
|
||||||
|
|
||||||
|
const RUN = { id: 1, scope: '' }
|
||||||
|
const ADMIN = { id: 1, role: 'admin' }
|
||||||
|
const EDITOR = { id: 2, role: 'editor' }
|
||||||
|
const MODERATOR = { id: 3, role: 'moderator' }
|
||||||
|
|
||||||
|
// ── Layer 2: the role floor ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the role floor falls between inspect and change, not between notify and inspect', async () => {
|
||||||
|
// The decision, held as a test rather than as a comment. Read §K's sentence
|
||||||
|
// literally and an editor cannot author a step that WAITS, which is an
|
||||||
|
// authoring role that cannot author.
|
||||||
|
register([
|
||||||
|
action('test.tell', { risk: 'notify' }),
|
||||||
|
action('test.look', { risk: 'inspect' }),
|
||||||
|
action('test.change', { risk: 'change' }),
|
||||||
|
action('test.burn', { risk: 'irreversible' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const id of ['test.tell', 'test.look']) {
|
||||||
|
const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) })
|
||||||
|
assert.equal(v.ok, true, `${id} should be open to an editor`)
|
||||||
|
}
|
||||||
|
for (const id of ['test.change', 'test.burn']) {
|
||||||
|
const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) })
|
||||||
|
assert.equal(v.ok, false)
|
||||||
|
assert.equal(v.code, 'role')
|
||||||
|
assert.match(v.reason, /only an administrator/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a moderator is no more able to invoke a world-changing action than an editor', async () => {
|
||||||
|
// The moderator's entire power over this feature is the run console (§K). The
|
||||||
|
// floor is `admin`, not "not a player".
|
||||||
|
register([action('test.change', { risk: 'change' })])
|
||||||
|
setSetting('test.change', true)
|
||||||
|
const v = await authorize.mayInvoke({ user: MODERATOR, action: registries.eventAction('test.change') })
|
||||||
|
assert.equal(v.code, 'role')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an admin passes the role layer for every risk class', async () => {
|
||||||
|
register([action('test.burn', { risk: 'irreversible' })])
|
||||||
|
setSetting('test.burn', true)
|
||||||
|
const v = await authorize.mayInvoke({ user: ADMIN, action: registries.eventAction('test.burn') })
|
||||||
|
assert.equal(v.ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a null user skips the role layer, because the runner is not a person', async () => {
|
||||||
|
// The unattended path. The role was checked when a human published the version
|
||||||
|
// and again when a human or the scheduler started the run; re-checking here
|
||||||
|
// would mean demoting an admin at midnight silently strands every event they
|
||||||
|
// started.
|
||||||
|
register([action('test.burn', { risk: 'irreversible' })])
|
||||||
|
setSetting('test.burn', true)
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.burn') })
|
||||||
|
assert.equal(v.ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Layer 3a: enablement ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('nothing that changes the world is enabled by default, and everything else is', async () => {
|
||||||
|
register([
|
||||||
|
action('test.tell', { risk: 'notify' }),
|
||||||
|
action('test.look', { risk: 'inspect' }),
|
||||||
|
action('test.change', { risk: 'change' }),
|
||||||
|
action('test.burn', { risk: 'irreversible' }),
|
||||||
|
])
|
||||||
|
const enabled = (id) => authorize.isEnabled(registries.eventAction(id), null)
|
||||||
|
assert.equal(enabled('test.tell'), true)
|
||||||
|
assert.equal(enabled('test.look'), true)
|
||||||
|
assert.equal(enabled('test.change'), false)
|
||||||
|
assert.equal(enabled('test.burn'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stored row beats the risk-class default in both directions', async () => {
|
||||||
|
// The switch is the operator's, and it has to be able to turn a `notify` action
|
||||||
|
// OFF as well as a `change` action on. An "enable only" switchboard would leave
|
||||||
|
// a deployment unable to stop an announcement it did not want.
|
||||||
|
register([action('test.tell', { risk: 'notify' }), action('test.change', { risk: 'change' })])
|
||||||
|
setSetting('test.tell', false)
|
||||||
|
setSetting('test.change', true)
|
||||||
|
|
||||||
|
const off = await authorize.mayInvoke({ action: registries.eventAction('test.tell') })
|
||||||
|
assert.equal(off.ok, false)
|
||||||
|
assert.equal(off.code, 'disabled')
|
||||||
|
|
||||||
|
const on = await authorize.mayInvoke({ action: registries.eventAction('test.change') })
|
||||||
|
assert.equal(on.ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the refusal names the action by its LABEL, not its id', async () => {
|
||||||
|
// The reason is rendered to an operator on the run console. `"Spawn creatures"
|
||||||
|
// is not enabled on this deployment` is a sentence; the id is a slug.
|
||||||
|
register([action('test.change', { risk: 'change', label: 'Spawn creatures' })])
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.change') })
|
||||||
|
assert.match(v.reason, /"Spawn creatures" is not enabled/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Pricing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('cost() is called with the step params and its answer is what core enforces', async () => {
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||||||
|
setSetting('test.spawn', true)
|
||||||
|
const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 })
|
||||||
|
assert.deepEqual(priced, { 'test.creatures': 12 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a cost() that throws makes the action unpriceable, never free', async () => {
|
||||||
|
register([
|
||||||
|
action('test.spawn', {
|
||||||
|
risk: 'change',
|
||||||
|
cost: () => {
|
||||||
|
throw new Error('nope')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setSetting('test.spawn', true)
|
||||||
|
assert.equal(authorize.priceOf(registries.eventAction('test.spawn'), {}), null)
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
|
||||||
|
assert.equal(v.ok, false)
|
||||||
|
assert.equal(v.code, 'unpriceable')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a cost() answering a non-object, a negative or a NaN is unpriceable too', async () => {
|
||||||
|
register([
|
||||||
|
action('test.a', { risk: 'change', cost: () => 5 }),
|
||||||
|
action('test.b', { risk: 'change', cost: () => ({ d: -1 }) }),
|
||||||
|
action('test.c', { risk: 'change', cost: () => ({ d: 'lots' }) }),
|
||||||
|
action('test.d', { risk: 'change', cost: () => [1, 2] }),
|
||||||
|
])
|
||||||
|
for (const id of ['test.a', 'test.b', 'test.c', 'test.d']) {
|
||||||
|
assert.equal(authorize.priceOf(registries.eventAction(id), {}), null, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a zero cost is dropped rather than becoming a dimension with no spend', async () => {
|
||||||
|
// A dimension present at 0 would be seeded as a budget row nothing ever draws
|
||||||
|
// on, and it would appear on the console's meter as "0 of 30" for a verb this
|
||||||
|
// event never uses.
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: () => ({ a: 0, b: 3 }) })])
|
||||||
|
assert.deepEqual(authorize.priceOf(registries.eventAction('test.spawn'), {}), { b: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action with no cost() costs nothing and never touches the budget', async () => {
|
||||||
|
register([action('test.tell')])
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.tell'), run: RUN, spend: true })
|
||||||
|
assert.equal(v.ok, true)
|
||||||
|
assert.deepEqual(v.cost, {})
|
||||||
|
// No budget row exists for this run at all, and that is not a refusal: an
|
||||||
|
// action that spends nothing has nothing to be refused over.
|
||||||
|
assert.equal(store.budget.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dimensions are discovered by pricing the declared examples', async () => {
|
||||||
|
// The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
|
||||||
|
// 7. Every param carries a required `example` precisely so a form has something
|
||||||
|
// to show, and pricing them is what lets the switchboard offer a cap box.
|
||||||
|
register([
|
||||||
|
action('test.spawn', {
|
||||||
|
risk: 'change',
|
||||||
|
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||||||
|
cost: (p) => ({ 'test.creatures': p.count, 'test.bosses': 1 }),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['test.bosses', 'test.creatures'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action whose cost() cannot survive its own examples reports no dimensions', async () => {
|
||||||
|
// Honest rather than clever: the operator loses a cap box, and the RUN loses
|
||||||
|
// nothing, because a run's budget is seeded from the params its steps were
|
||||||
|
// actually authored with.
|
||||||
|
register([
|
||||||
|
action('test.spawn', {
|
||||||
|
risk: 'change',
|
||||||
|
cost: (p) => ({ d: p.missing.count }),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── effectiveCaps: the tightest cap wins ───────────────────────────────────
|
||||||
|
|
||||||
|
test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => {
|
||||||
|
register([
|
||||||
|
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||||
|
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||||
|
])
|
||||||
|
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||||
|
setSetting('test.horde', true, { 'test.creatures': 10 })
|
||||||
|
|
||||||
|
const caps = authorize.effectiveCaps(
|
||||||
|
[{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }],
|
||||||
|
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
||||||
|
)
|
||||||
|
assert.deepEqual(caps, { 'test.creatures': { cap: 10, from: 'test.horde' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => {
|
||||||
|
// `null` is uncapped and must never win a minimum. Otherwise adding a second
|
||||||
|
// verb to an event would silently remove the bound on the first.
|
||||||
|
register([
|
||||||
|
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||||
|
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||||
|
])
|
||||||
|
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||||
|
setSetting('test.horde', true, {})
|
||||||
|
|
||||||
|
const caps = authorize.effectiveCaps(
|
||||||
|
[{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }],
|
||||||
|
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
||||||
|
)
|
||||||
|
assert.deepEqual(caps, { 'test.creatures': { cap: 30, from: 'test.spawn' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dimension nobody caps is still a row, uncapped', async () => {
|
||||||
|
// Seeded so the meter counts it. The distinction it preserves is that a MISSING
|
||||||
|
// row means something else entirely: a step spending a dimension its own run's
|
||||||
|
// version never priced.
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) })])
|
||||||
|
setSetting('test.spawn', true, {})
|
||||||
|
const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn']))
|
||||||
|
assert.deepEqual(caps, { 'test.creatures': { cap: null, from: null } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a step naming an unregistered action contributes no dimension', async () => {
|
||||||
|
assert.deepEqual(authorize.effectiveCaps([{ actionId: 'nobody.registers', params: {} }], new Map()), {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Layer 3b: the cap ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('with no run, the question is whether the cost could EVER fit', async () => {
|
||||||
|
// The dry run's and the editor's question. A step asking for 40 under a cap of
|
||||||
|
// 30 is an authoring error answerable before anything is scheduled, which is
|
||||||
|
// the entire value of catching it here rather than at 2am.
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||||||
|
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||||
|
|
||||||
|
const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } })
|
||||||
|
assert.equal(bad.ok, false)
|
||||||
|
assert.equal(bad.code, 'cap')
|
||||||
|
assert.equal(bad.dimension, 'test.creatures')
|
||||||
|
assert.equal(bad.requested, 40)
|
||||||
|
assert.equal(bad.cap, 30)
|
||||||
|
|
||||||
|
const fine = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 30 } })
|
||||||
|
assert.equal(fine.ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('with a run and spend, the check IS the spend', async () => {
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||||||
|
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||||
|
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||||
|
|
||||||
|
const v = await authorize.mayInvoke({
|
||||||
|
action: registries.eventAction('test.spawn'),
|
||||||
|
params: { count: 12 },
|
||||||
|
run: RUN,
|
||||||
|
spend: true,
|
||||||
|
})
|
||||||
|
assert.equal(v.ok, true)
|
||||||
|
assert.equal(v.spent, true)
|
||||||
|
assert.equal(store.budget.get('1:test.creatures').consumed, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('without spend the cap check is advisory and moves nothing', async () => {
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
|
||||||
|
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||||
|
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||||
|
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
|
||||||
|
assert.equal(v.ok, true)
|
||||||
|
assert.equal(v.spent, undefined)
|
||||||
|
assert.equal(store.budget.get('1:test.creatures').consumed, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a refusal names the numbers an operator needs, not just "refused"', async () => {
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
|
||||||
|
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||||
|
setBudget(RUN.id, 'test.creatures', { consumed: 28, cap: 30 })
|
||||||
|
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||||
|
assert.equal(v.ok, false)
|
||||||
|
assert.equal(v.code, 'cap')
|
||||||
|
assert.equal(v.consumed, 28)
|
||||||
|
assert.equal(v.cap, 30)
|
||||||
|
assert.equal(v.requested, 5)
|
||||||
|
assert.match(v.reason, /28 of 30 is already spent this run/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a partial spend across dimensions is given back when a later one is refused', async () => {
|
||||||
|
// The property the whole multi-dimension path turns on. The spends must be
|
||||||
|
// separate statements — the atomicity that matters is per dimension — so a step
|
||||||
|
// costing creatures AND bosses can take the creatures and be refused the
|
||||||
|
// bosses, and a step that did not run must not have spent anything.
|
||||||
|
register([
|
||||||
|
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5, 'test.bosses': 2 }) }),
|
||||||
|
])
|
||||||
|
setSetting('test.spawn', true)
|
||||||
|
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||||
|
setBudget(RUN.id, 'test.bosses', { consumed: 1, cap: 2 })
|
||||||
|
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||||
|
assert.equal(v.ok, false)
|
||||||
|
assert.equal(v.dimension, 'test.bosses')
|
||||||
|
assert.equal(store.budget.get('1:test.creatures').consumed, 0, 'the creatures must have been given back')
|
||||||
|
assert.equal(store.budget.get('1:test.bosses').consumed, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spending a dimension the run has no row for is refused, and says so in its own words', async () => {
|
||||||
|
// Fail-closed, and a distinct code: "this run has no budget for that" is a
|
||||||
|
// different diagnosis from "the cap is spent", and an operator who raises the
|
||||||
|
// cap in answer to the wrong one has not fixed anything.
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })])
|
||||||
|
setSetting('test.spawn', true)
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||||
|
assert.equal(v.ok, false)
|
||||||
|
assert.equal(v.code, 'unbudgeted')
|
||||||
|
assert.match(v.reason, /no budget for/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dimension no module declares is refused before any cap arithmetic', async () => {
|
||||||
|
// §F, fail closed (org lead, 2026-09-03). The refusal has to be its OWN code:
|
||||||
|
// told "the cap is spent", an operator goes and raises a cap, and nothing
|
||||||
|
// changes, because the problem is a module whose declaration is incomplete.
|
||||||
|
register([action('test.spawn', { risk: 'notify', cost: () => ({ 'test.wisps': 3 }) })], {
|
||||||
|
budgets: false,
|
||||||
|
})
|
||||||
|
setSetting('test.spawn', true)
|
||||||
|
setBudget(1, 'test.wisps', { cap: 100 })
|
||||||
|
|
||||||
|
const verdict = await authorize.mayInvoke({
|
||||||
|
action: registries.eventAction('test.spawn'),
|
||||||
|
params: {},
|
||||||
|
run: RUN,
|
||||||
|
spend: true,
|
||||||
|
})
|
||||||
|
assert.equal(verdict.ok, false)
|
||||||
|
assert.equal(verdict.code, 'undeclared')
|
||||||
|
assert.match(verdict.reason, /no module declares/)
|
||||||
|
// And it did not spend: a step refused for this reason never ran.
|
||||||
|
assert.equal(store.budget.get('1:test.wisps').consumed, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an uncapped budget row never refuses', async () => {
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 999 }) })])
|
||||||
|
setSetting('test.spawn', true)
|
||||||
|
setBudget(RUN.id, 'test.creatures', { consumed: 5, cap: null })
|
||||||
|
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||||
|
assert.equal(v.ok, true)
|
||||||
|
assert.equal(store.budget.get('1:test.creatures').consumed, 1004)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The layers are ordered ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the layers answer in order: role before enablement before cap', async () => {
|
||||||
|
// Not cosmetic. An editor told "that action is disabled" would go and ask an
|
||||||
|
// admin to enable it, and still not be allowed to author the step — the honest
|
||||||
|
// first answer is the role.
|
||||||
|
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })])
|
||||||
|
// Disabled AND over cap AND world-changing, all at once.
|
||||||
|
const v = await authorize.mayInvoke({
|
||||||
|
user: EDITOR,
|
||||||
|
action: registries.eventAction('test.spawn'),
|
||||||
|
run: RUN,
|
||||||
|
})
|
||||||
|
assert.equal(v.code, 'role')
|
||||||
|
|
||||||
|
setSetting('test.spawn', false, { 'test.creatures': 1 })
|
||||||
|
const asAdmin = await authorize.mayInvoke({
|
||||||
|
user: ADMIN,
|
||||||
|
action: registries.eventAction('test.spawn'),
|
||||||
|
run: RUN,
|
||||||
|
})
|
||||||
|
assert.equal(asAdmin.code, 'disabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action nobody registers is refused rather than thrown at', async () => {
|
||||||
|
const v = await authorize.mayInvoke({ action: null })
|
||||||
|
assert.equal(v.ok, false)
|
||||||
|
assert.equal(v.code, 'unregistered')
|
||||||
|
})
|
||||||
620
server/test/eventCleanup.test.js
Normal file
620
server/test/eventCleanup.test.js
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
// ── Giving back what a run took (EVENTS_PLAN.md Phase 8) ───────────────────
|
||||||
|
//
|
||||||
|
// The phase's shipped claim: **cleanup is generated from the ledger and runs on
|
||||||
|
// every terminal path.** 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, no `on_teardown` on an action, and one function that
|
||||||
|
// reads rows.
|
||||||
|
//
|
||||||
|
// The properties around it are §L's, and two of them are ones this codebase has
|
||||||
|
// already paid for once:
|
||||||
|
//
|
||||||
|
// • **a revert that never succeeds stays visible rather than cycling** — rule 2,
|
||||||
|
// and `MAX_REVERT_ATTEMPTS` is what stops the automatic retry. Only a human
|
||||||
|
// clears the counter, which is Engagement Phase 14's rule stated a third time
|
||||||
|
// • **reverting something that does not exist is a SUCCESS** — §L, and what a
|
||||||
|
// Rust wipe needs
|
||||||
|
// • **drift is not an error.** The module did exactly what it was asked and
|
||||||
|
// found somebody else's value in place. A restore that wrote anyway would
|
||||||
|
// silently revert an operator's manual fix
|
||||||
|
// • **a resource the module no longer has becomes `orphaned`, never `reverted`** —
|
||||||
|
// reverting it would be core recording that it put something back when what
|
||||||
|
// happened is that the thing vanished
|
||||||
|
// • **"I do not know" is never read as "it is gone".** Every unanswerable
|
||||||
|
// reconcile leaves the ledger alone
|
||||||
|
//
|
||||||
|
// The sweep is driven against a stubbed db layer, exactly as `eventRunner.test.js`
|
||||||
|
// drives the runner: what a stub cannot prove is the SQL, and the unique key that
|
||||||
|
// makes two events unable to lease one target runs against a real MariaDB in
|
||||||
|
// `eventRunnerSql.test.js`.
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const registries = require('../src/modules/registries')
|
||||||
|
const cleanup = require('../src/events/cleanup')
|
||||||
|
const resourcesDb = require('../src/model/events/eventRunResources.db')
|
||||||
|
const runsDb = require('../src/model/events/eventRuns.db')
|
||||||
|
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||||
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const UNRESOLVED = ['pending', 'confirmed', 'reverting', 'orphaned', 'drifted']
|
||||||
|
|
||||||
|
let store
|
||||||
|
const originals = {
|
||||||
|
resourcesDb: { ...resourcesDb },
|
||||||
|
runsDb: { ...runsDb },
|
||||||
|
stepsDb: { ...stepsDb },
|
||||||
|
logDb: { ...logDb },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registries._reset()
|
||||||
|
store = { rows: new Map(), steps: new Map(), log: [], next: 1, cleanupStatus: 'pending' }
|
||||||
|
|
||||||
|
resourcesDb.unresolvedForRun = async (runId, { maxAttempts = null } = {}) =>
|
||||||
|
[...store.rows.values()]
|
||||||
|
.filter((r) => r.run_id === runId && UNRESOLVED.includes(r.status))
|
||||||
|
.filter((r) => maxAttempts === null || r.revert_attempts < maxAttempts)
|
||||||
|
.map((r) => ({ ...r }))
|
||||||
|
resourcesDb.unresolvedCount = async (runId) =>
|
||||||
|
[...store.rows.values()].filter((r) => r.run_id === runId && UNRESOLVED.includes(r.status)).length
|
||||||
|
resourcesDb.claimRevert = async (id) => {
|
||||||
|
const r = store.rows.get(id)
|
||||||
|
if (!r || !['pending', 'confirmed', 'orphaned', 'drifted'].includes(r.status)) return false
|
||||||
|
r.status = 'reverting'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
resourcesDb.markReverted = async (id) => {
|
||||||
|
const r = store.rows.get(id)
|
||||||
|
if (r) Object.assign(r, { status: 'reverted', last_error: null })
|
||||||
|
}
|
||||||
|
resourcesDb.failRevert = async (id, error, restoreTo = 'confirmed') => {
|
||||||
|
const r = store.rows.get(id)
|
||||||
|
if (r) Object.assign(r, { status: restoreTo, revert_attempts: r.revert_attempts + 1, last_error: String(error) })
|
||||||
|
}
|
||||||
|
resourcesDb.resetAttempts = async (runId) => {
|
||||||
|
let n = 0
|
||||||
|
for (const r of store.rows.values()) {
|
||||||
|
if (r.run_id === runId && UNRESOLVED.includes(r.status)) {
|
||||||
|
r.revert_attempts = 0
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
resourcesDb.markOrphaned = async (id, detail = null) => {
|
||||||
|
const r = store.rows.get(id)
|
||||||
|
if (r && ['pending', 'confirmed', 'reverting'].includes(r.status)) {
|
||||||
|
Object.assign(r, { status: 'orphaned', last_error: detail })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resourcesDb.liveForModule = async (owner) =>
|
||||||
|
[...store.rows.values()].filter((r) => r.owner_module === owner && ['pending', 'confirmed'].includes(r.status)).map((r) => ({ ...r }))
|
||||||
|
resourcesDb.modulesWithLiveRows = async () => [
|
||||||
|
...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)),
|
||||||
|
]
|
||||||
|
resourcesDb.runsNeedingCleanup = async () => store.candidates || []
|
||||||
|
|
||||||
|
runsDb.setCleanupStatus = async (id, to, from = null) => {
|
||||||
|
if (from && !from.includes(store.cleanupStatus)) return false
|
||||||
|
store.cleanupStatus = to
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stepsDb.getById = async (id) => store.steps.get(id) || null
|
||||||
|
logDb.write = async (entry) => {
|
||||||
|
store.log.push(entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(resourcesDb, originals.resourcesDb)
|
||||||
|
Object.assign(runsDb, originals.runsDb)
|
||||||
|
Object.assign(stepsDb, originals.stepsDb)
|
||||||
|
Object.assign(logDb, originals.logDb)
|
||||||
|
registries._reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
const RUN = { id: 7, status: 'completed', cleanup_status: 'pending' }
|
||||||
|
|
||||||
|
function addStep(id, actionId, key = 'k'.repeat(40)) {
|
||||||
|
store.steps.set(id, { id, run_id: RUN.id, action_id: actionId, idempotency_key: key, phase: 'p', seq: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function addResource(over = {}) {
|
||||||
|
const id = store.next++
|
||||||
|
const row = {
|
||||||
|
id,
|
||||||
|
run_id: RUN.id,
|
||||||
|
step_id: 1,
|
||||||
|
owner_module: 'demo',
|
||||||
|
kind: 'creature',
|
||||||
|
ref: `0x${id}`,
|
||||||
|
payload: null,
|
||||||
|
lease_until: null,
|
||||||
|
status: 'confirmed',
|
||||||
|
revert_attempts: 0,
|
||||||
|
last_error: null,
|
||||||
|
member_key: null,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
store.rows.set(id, row)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerAction(over = {}) {
|
||||||
|
const api = registries.stage('demo')
|
||||||
|
api.registerEventActions([
|
||||||
|
{
|
||||||
|
id: 'demo.spawn',
|
||||||
|
label: 'Spawn',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
budgetMs: 500,
|
||||||
|
perform: async () => ({ ok: true }),
|
||||||
|
revert: async () => ({ ok: true }),
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
registries.apply(api.staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerLease(over = {}) {
|
||||||
|
const api = registries.stage('demo')
|
||||||
|
api.registerEventLeases([
|
||||||
|
{
|
||||||
|
id: 'demo.rate',
|
||||||
|
label: 'Gather rate',
|
||||||
|
type: 'float',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
maxDurationMs: 3_600_000,
|
||||||
|
read: async () => ({ ok: true, value: 1 }),
|
||||||
|
apply: async () => ({ ok: true }),
|
||||||
|
restore: async () => ({ ok: true }),
|
||||||
|
...over,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
registries.apply(api.staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kinds = () => store.log.map((e) => e.kind)
|
||||||
|
|
||||||
|
// ── The shipped claim ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a run\'s ledger is given back in one call per step, and the run goes complete', async () => {
|
||||||
|
// `revert` takes a LIST because that is what makes twelve creatures one round
|
||||||
|
// trip rather than twelve. The grouping key is the STEP, because the resource
|
||||||
|
// row records the module and the opaque names while the step records the verb.
|
||||||
|
let seen = null
|
||||||
|
registerAction({ revert: async (arg) => { seen = arg; return { ok: true } } })
|
||||||
|
addStep(1, 'demo.spawn', 'key-one')
|
||||||
|
addResource({ ref: '0xA' })
|
||||||
|
addResource({ ref: '0xB' })
|
||||||
|
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.deepEqual(summary, { attempted: 2, reverted: 2, drifted: 0, failed: 0, remaining: 0 })
|
||||||
|
assert.equal(seen.runId, RUN.id)
|
||||||
|
assert.equal(seen.idempotencyKey, 'key-one')
|
||||||
|
assert.deepEqual(seen.resources.map((r) => r.ref), ['0xA', '0xB'])
|
||||||
|
assert.equal(store.cleanupStatus, 'complete')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two steps are two calls, and one failing does not take the other down', async () => {
|
||||||
|
const calls = []
|
||||||
|
registerAction({
|
||||||
|
revert: async ({ idempotencyKey, resources }) => {
|
||||||
|
calls.push(idempotencyKey)
|
||||||
|
return idempotencyKey === 'bad' ? { ok: false, error: 'the shard did not answer' } : { ok: true, resources }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
addStep(1, 'demo.spawn', 'good')
|
||||||
|
addStep(2, 'demo.spawn', 'bad')
|
||||||
|
addResource({ step_id: 1 })
|
||||||
|
addResource({ step_id: 2 })
|
||||||
|
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(calls.length, 2)
|
||||||
|
assert.equal(summary.reverted, 1)
|
||||||
|
assert.equal(summary.failed, 1)
|
||||||
|
assert.equal(summary.remaining, 1)
|
||||||
|
// **Still `pending`, because the failed row has retries left.** `incomplete`
|
||||||
|
// means "finished with, and not finished" — it is what takes a run out of the
|
||||||
|
// sweep's own scan, so writing it after the FIRST failure made
|
||||||
|
// `MAX_REVERT_ATTEMPTS` quietly mean one attempt. Found by watching
|
||||||
|
// `revert_attempts` sit at 1 through half a minute of live ticks.
|
||||||
|
assert.equal(store.cleanupStatus, 'pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reverting something that does not exist is a success', async () => {
|
||||||
|
// §L, and the Rust wipe: "gone, and that is fine". The module never has to
|
||||||
|
// distinguish "I deleted it" from "it was not there" — which is also what makes
|
||||||
|
// a placeholder for an object that may never have existed safe to write.
|
||||||
|
registerAction({ revert: async () => ({ ok: true, detail: 'resource no longer exists' }) })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.reverted, 1)
|
||||||
|
assert.equal(store.cleanupStatus, 'complete')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module may name the ones that did not come back', async () => {
|
||||||
|
// Partial cleanup is the ordinary case — eleven of twelve creatures deleted —
|
||||||
|
// and it is why the ledger is a row per object rather than a row per step.
|
||||||
|
registerAction({ revert: async () => ({ ok: true, failed: ['0x2'] }) })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource({ ref: '0x1' })
|
||||||
|
addResource({ ref: '0x2' })
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.reverted, 1)
|
||||||
|
assert.equal(summary.failed, 1)
|
||||||
|
assert.match([...store.rows.values()].find((r) => r.ref === '0x2').last_error, /could not give "0x2" back/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rule 2: loud and sticky ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a revert that never works stops retrying and stays visible', async () => {
|
||||||
|
let calls = 0
|
||||||
|
registerAction({ revert: async () => { calls += 1; return { ok: false, error: 'nope' } } })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) await cleanup.cleanupRun(RUN)
|
||||||
|
|
||||||
|
// Bounded at MAX_REVERT_ATTEMPTS, exactly like a step's attempts. A fourth ask
|
||||||
|
// of a shard that has answered the same way three times is not new information,
|
||||||
|
// and an unbounded counter is a row nothing can ever sweep.
|
||||||
|
assert.equal(calls, cleanup.MAX_REVERT_ATTEMPTS)
|
||||||
|
const row = [...store.rows.values()][0]
|
||||||
|
assert.equal(row.revert_attempts, cleanup.MAX_REVERT_ATTEMPTS)
|
||||||
|
assert.equal(row.status, 'confirmed')
|
||||||
|
assert.equal(row.last_error, 'nope')
|
||||||
|
// And ONLY now, with nothing left to try, does the run stop being the sweep's
|
||||||
|
// business. §L: it does not stay `running` — an event whose world changes are
|
||||||
|
// still up is a real state, and pretending the event is in progress hides it.
|
||||||
|
assert.equal(store.cleanupStatus, 'incomplete')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the run goes back to pending each time it still has an attempt left', async () => {
|
||||||
|
// The other half of the same rule, watched one pass at a time rather than at
|
||||||
|
// the end. Each of the first two sweeps leaves the run in the scan; the third
|
||||||
|
// takes it out. A test that only looked at the end state would pass against the
|
||||||
|
// defect this replaced.
|
||||||
|
registerAction({ revert: async () => ({ ok: false, error: 'nope' }) })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
|
||||||
|
const seen = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await cleanup.cleanupRun(RUN)
|
||||||
|
seen.push(store.cleanupStatus)
|
||||||
|
}
|
||||||
|
assert.deepEqual(seen, ['pending', 'pending', 'incomplete'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only a human clears the attempt counter', async () => {
|
||||||
|
// Engagement Phase 14's defect, stated a third time: a SWEEP that returned every
|
||||||
|
// stale row to its start state made the attempt ceiling unreachable, so the row
|
||||||
|
// cycled for ever and was never eligible for any retention sweep. The automatic
|
||||||
|
// leg must never do this; the cleanup route may, because a person asked.
|
||||||
|
let calls = 0
|
||||||
|
registerAction({ revert: async () => { calls += 1; return { ok: false, error: 'nope' } } })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(calls, cleanup.MAX_REVERT_ATTEMPTS)
|
||||||
|
|
||||||
|
await cleanup.cleanupRun(RUN, { resetAttempts: true, actor: 9 })
|
||||||
|
assert.equal(calls, cleanup.MAX_REVERT_ATTEMPTS + 1)
|
||||||
|
assert.ok(store.log.some((e) => e.kind === 'cleanup.retry' && e.detail.by === 9))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module that throws from revert is a transient failure, not a crashed sweep', async () => {
|
||||||
|
registerAction({ revert: async () => { throw new Error('socket hung up') } })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.failed, 1)
|
||||||
|
assert.match([...store.rows.values()][0].last_error, /socket hung up/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no shape a revert failure can take reads as success', () => {
|
||||||
|
// `dispatch.classify`'s rule, applied to the other direction of the contract.
|
||||||
|
// The expensive mistake here is the mirror of the one there: recording that a
|
||||||
|
// world change was UNDONE when it was not.
|
||||||
|
for (const raw of [null, undefined, 'ok', [], {}, { ok: 'yes' }, { ok: 1 }]) {
|
||||||
|
assert.notEqual(cleanup.classifyRevert(raw, 'demo.spawn').outcome, 'done', JSON.stringify(raw))
|
||||||
|
}
|
||||||
|
assert.equal(cleanup.classifyRevert({ __timedOut: true, error: 'slow' }, 'x').outcome, 'retry')
|
||||||
|
assert.equal(cleanup.classifyRevert({ ok: false, retry: false, error: 'never' }, 'x').outcome, 'terminal')
|
||||||
|
assert.equal(cleanup.classifyRevert({ ok: true }, 'x').outcome, 'done')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action whose module is gone leaves its rows unresolved with the reason', async () => {
|
||||||
|
// Not a retry — nothing will change until an operator reinstalls it — and not
|
||||||
|
// an orphan either, because core has no idea whether the thing is still there.
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.failed, 1)
|
||||||
|
assert.match([...store.rows.values()][0].last_error, /no module registers "demo.spawn"/)
|
||||||
|
// Retried like any other failure rather than given up on at once, and that is
|
||||||
|
// the right uniformity here: "nothing registers this" stops being true the
|
||||||
|
// moment an operator reinstalls the module, and three registry lookups cost
|
||||||
|
// nothing. So it is `pending` until the attempts are spent.
|
||||||
|
assert.equal(store.cleanupStatus, 'pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Leases ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a lease is restored through the LEASE registry, not through any action', async () => {
|
||||||
|
// The split §F draws: core owns the duration and the conflict check, the module
|
||||||
|
// owns reading and writing. It is why `core.lease` needs no `revert()` of its
|
||||||
|
// own, and why an `override` row routes here rather than to its step's action.
|
||||||
|
let seen = null
|
||||||
|
registerLease({ restore: async (baseline, opts) => { seen = { baseline, opts }; return { ok: true } } })
|
||||||
|
addResource({ kind: 'override', ref: 'demo.rate', step_id: null, payload: { baseline: 1, applied: 3 } })
|
||||||
|
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.reverted, 1)
|
||||||
|
assert.equal(seen.baseline, 1)
|
||||||
|
// The drift check's input. `restore` MUST verify current === expected before
|
||||||
|
// writing, and a lease whose restore wrote blindly would silently revert an
|
||||||
|
// operator's manual fix.
|
||||||
|
assert.equal(seen.opts.expected, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drift is not an error: the world is left alone and the row says so', async () => {
|
||||||
|
registerLease({ restore: async () => ({ ok: false, drifted: true, current: 4.5 }) })
|
||||||
|
addResource({ kind: 'override', ref: 'demo.rate', step_id: null, payload: { baseline: 1, applied: 3 } })
|
||||||
|
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.drifted, 1)
|
||||||
|
assert.equal(summary.failed, 0)
|
||||||
|
const row = [...store.rows.values()][0]
|
||||||
|
assert.equal(row.status, 'drifted')
|
||||||
|
assert.match(row.last_error, /now 4\.5 rather than what this run applied/)
|
||||||
|
// Still surfaced. §L: "surfaced beside the unreverted ones" — the run does not
|
||||||
|
// get to call itself clean because somebody else took the value. It is `pending`
|
||||||
|
// rather than `incomplete` for one more reason worth keeping: drift is retried
|
||||||
|
// like any other failure, because a GM who puts the value back between two ticks
|
||||||
|
// should have the lease close cleanly.
|
||||||
|
assert.equal(store.cleanupStatus, 'pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lease whose module is uninstalled is unresolved, never assumed restored', async () => {
|
||||||
|
addResource({ kind: 'override', ref: 'demo.rate', step_id: null, payload: { baseline: 1, applied: 3 } })
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.failed, 1)
|
||||||
|
assert.match([...store.rows.values()][0].last_error, /no module registers the lease "demo.rate"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a targeted lease is restored through the lease its ref NAMES, target in hand', async () => {
|
||||||
|
// Phase 12b. A targeted row's ref is `<lease id>#<target>`, and both readers of
|
||||||
|
// one — this sweep and `ledger.normalise` — looked their lease up by the whole
|
||||||
|
// string. Left alone, every property lease would have come back "no module
|
||||||
|
// registers the lease", which is the worst answer this table has: it leaves a
|
||||||
|
// spawner turned up for good AND blames an uninstalled module for it.
|
||||||
|
let seen = null
|
||||||
|
registerLease({
|
||||||
|
id: 'demo.spawner.count',
|
||||||
|
type: 'int',
|
||||||
|
min: 0,
|
||||||
|
max: 50,
|
||||||
|
target: { label: 'Which spawner' },
|
||||||
|
restore: async (baseline, opts) => { seen = { baseline, opts }; return { ok: true } },
|
||||||
|
})
|
||||||
|
addResource({
|
||||||
|
kind: 'override',
|
||||||
|
ref: 'demo.spawner.count#003f11b8-9bfa',
|
||||||
|
step_id: null,
|
||||||
|
payload: { baseline: 3, applied: 30 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.reverted, 1)
|
||||||
|
assert.equal(seen.baseline, 3)
|
||||||
|
assert.equal(seen.opts.expected, 30)
|
||||||
|
// The half that makes the restore land on the right object rather than on some
|
||||||
|
// other run's spawner: the module is handed the target, not asked to parse the
|
||||||
|
// ref core composed.
|
||||||
|
assert.equal(seen.opts.target, '003f11b8-9bfa')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unregistered TARGETED lease still names itself, ref and all', async () => {
|
||||||
|
// The fail-closed direction is unchanged by the parser: a row whose module is
|
||||||
|
// gone stays unresolved with the reason on it, and the reason quotes the ref
|
||||||
|
// the operator will actually see in the console rather than the bare id.
|
||||||
|
addResource({
|
||||||
|
kind: 'override',
|
||||||
|
ref: 'demo.spawner.count#003f11b8-9bfa',
|
||||||
|
step_id: null,
|
||||||
|
payload: { baseline: 3, applied: 30 },
|
||||||
|
})
|
||||||
|
const summary = await cleanup.cleanupRun(RUN)
|
||||||
|
assert.equal(summary.failed, 1)
|
||||||
|
assert.match(
|
||||||
|
[...store.rows.values()][0].last_error,
|
||||||
|
/no module registers the lease "demo\.spawner\.count#003f11b8-9bfa"/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The sweep ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the sweep only touches TERMINAL runs', async () => {
|
||||||
|
// 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.
|
||||||
|
registerAction()
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
store.candidates = [{ id: RUN.id, status: 'running', cleanup_status: 'pending' }]
|
||||||
|
assert.equal(await cleanup.sweep(), 0)
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||||
|
|
||||||
|
store.candidates = [{ id: RUN.id, status: 'cancelled', cleanup_status: 'pending' }]
|
||||||
|
assert.equal(await cleanup.sweep(), 1)
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'reverted')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Reconcile ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a resource the module no longer has becomes orphaned, never reverted', async () => {
|
||||||
|
// §L, and the distinction matters to the operator reading the console
|
||||||
|
// afterwards: `reverted` says core put something back, `orphaned` says the
|
||||||
|
// thing vanished while nobody was looking. Recording the second as the first
|
||||||
|
// would be core claiming credit for a shard restart.
|
||||||
|
registerAction({ reconcile: async () => ({ ok: true, inForce: ['0x1'] }) })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource({ ref: '0x1' })
|
||||||
|
addResource({ ref: '0x2' })
|
||||||
|
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.deepEqual(summary, { asked: 2, inForce: 1, orphaned: 1, unanswered: 0 })
|
||||||
|
assert.equal([...store.rows.values()].find((r) => r.ref === '0x2').status, 'orphaned')
|
||||||
|
assert.equal([...store.rows.values()].find((r) => r.ref === '0x1').status, 'confirmed')
|
||||||
|
assert.ok(store.log.some((e) => e.kind === 'resource.orphaned'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('"I do not know" is never read as "it is gone"', async () => {
|
||||||
|
// Every unanswerable shape leaves the ledger exactly as it was. A reconcile
|
||||||
|
// that read silence as absence would orphan a whole shard's worth of live
|
||||||
|
// spawns the first time a sidecar was slow.
|
||||||
|
for (const answer of [null, undefined, { ok: false }, { ok: true }, { ok: true, inForce: 'all' }, 'yes']) {
|
||||||
|
store.rows.clear()
|
||||||
|
store.log.length = 0
|
||||||
|
registries._reset()
|
||||||
|
registerAction({ reconcile: async () => answer })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource({ ref: '0x1' })
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.equal(summary.orphaned, 0, JSON.stringify(answer))
|
||||||
|
assert.equal(summary.unanswered, 1, JSON.stringify(answer))
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module with no reconcile is not broken; core keeps believing its ledger', async () => {
|
||||||
|
// Optional where `revert` is required. A module that cannot answer leaves core
|
||||||
|
// exactly where it was before this phase, which is a capability its deployment
|
||||||
|
// does without rather than a boot it fails.
|
||||||
|
registerAction()
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 1 })
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a reconcile that throws orphans nothing', async () => {
|
||||||
|
registerAction({ reconcile: async () => { throw new Error('sidecar gone') } })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource()
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.equal(summary.unanswered, 1)
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('placeholders are not asked about, because there is nothing to ask yet', async () => {
|
||||||
|
// A `@step` row names no object — it says "a dispatch was in flight and may
|
||||||
|
// have made something". Asking a module whether it is in force is a question
|
||||||
|
// with no answer, and reading a shrug as absence would resolve the one row whose
|
||||||
|
// survival is the safety property.
|
||||||
|
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addResource({ kind: resourcesDb.STEP_KIND, ref: 'a'.repeat(40), status: 'pending' })
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 })
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Reconciling a LEASE (Phase 11b) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A lease row's action is `core.lease`, so these need core's own registrations.
|
||||||
|
function registerCoreAnd(leaseOver = {}) {
|
||||||
|
registries.registerCore()
|
||||||
|
registerLease(leaseOver)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a lease row had no reconcile path at all until core.lease grew one', async () => {
|
||||||
|
// The hole this phase closed, asserted from the outside. `reconcileModule`
|
||||||
|
// resolves a resource to the action of the step that made it, and for a lease
|
||||||
|
// that action is CORE's — a path no module can register anything on. So every
|
||||||
|
// `override` row came back `unanswered` for the life of the run, and a lease the
|
||||||
|
// shard had quietly dropped stayed in the ledger as live until teardown went
|
||||||
|
// looking for a baseline nobody was holding.
|
||||||
|
registerCoreAnd({ inForce: async () => ({ ok: true, held: false }) })
|
||||||
|
addStep(1, 'core.lease')
|
||||||
|
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||||
|
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.deepEqual(summary, { asked: 1, inForce: 0, orphaned: 1, unanswered: 0 })
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'orphaned')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lease the shard still has a record of stays put', async () => {
|
||||||
|
registerCoreAnd({ inForce: async () => ({ ok: true, held: true }) })
|
||||||
|
addStep(1, 'core.lease')
|
||||||
|
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||||
|
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.deepEqual(summary, { asked: 1, inForce: 1, orphaned: 0, unanswered: 0 })
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lease that cannot say is left alone, and DRIFT is not what this asks about', async () => {
|
||||||
|
// Two properties in one walk. Every unanswerable shape leaves the row exactly as
|
||||||
|
// it was, which is core's posture everywhere else — and a lease with no
|
||||||
|
// `inForce()` at all is one of those shapes rather than a boot failure.
|
||||||
|
//
|
||||||
|
// The second is the reason `inForce` exists instead of a comparison against
|
||||||
|
// `read()`: a value that differs from what the event applied is drift, and drift
|
||||||
|
// is teardown's verdict to deliver through `restore` so the row lands `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 — telling the
|
||||||
|
// operator the lease vanished rather than that somebody moved it.
|
||||||
|
const shrugs = [
|
||||||
|
undefined, // no inForce() declared at all
|
||||||
|
async () => null,
|
||||||
|
async () => ({ ok: false, held: false }), // could not ask; not an answer
|
||||||
|
async () => ({ ok: true }), // answered without saying
|
||||||
|
async () => { throw new Error('sidecar gone') },
|
||||||
|
// The drift shape. `read()` would report 4.5 against an applied 3, and this
|
||||||
|
// must NOT be read as "the lease is gone".
|
||||||
|
async () => ({ ok: true, held: true, current: 4.5 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const inForce of shrugs) {
|
||||||
|
store.rows.clear()
|
||||||
|
store.log.length = 0
|
||||||
|
registries._reset()
|
||||||
|
registerCoreAnd(inForce === undefined ? {} : { inForce })
|
||||||
|
addStep(1, 'core.lease')
|
||||||
|
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||||
|
|
||||||
|
const summary = await cleanup.reconcileModule('demo')
|
||||||
|
assert.equal(summary.orphaned, 0, String(inForce))
|
||||||
|
assert.equal([...store.rows.values()][0].status, 'confirmed', String(inForce))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reconcileAll asks every module that owns a live row', async () => {
|
||||||
|
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||||
|
addStep(1, 'demo.spawn')
|
||||||
|
addStep(2, 'other.thing')
|
||||||
|
addResource({ owner_module: 'demo' })
|
||||||
|
addResource({ owner_module: 'other', step_id: 2 })
|
||||||
|
const out = await cleanup.reconcileAll()
|
||||||
|
assert.deepEqual(Object.keys(out).sort(), ['demo', 'other'])
|
||||||
|
assert.equal(out.demo.orphaned, 1)
|
||||||
|
// The other module registers nothing, so its row is left alone rather than
|
||||||
|
// orphaned by a module that is not there to be asked.
|
||||||
|
assert.equal(out.other.unanswered, 1)
|
||||||
|
})
|
||||||
267
server/test/eventGates.test.js
Normal file
267
server/test/eventGates.test.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// ── Phase advance gates (EVENTS_PLAN.md Phase 5) ───────────────────────────
|
||||||
|
//
|
||||||
|
// The runner's half of this is in `eventRunner.test.js`, where a gate holds a
|
||||||
|
// phase and lets it go. This file is the other half — the two things that only
|
||||||
|
// exist because an operator has to READ them:
|
||||||
|
//
|
||||||
|
// • `phrase()`, which renders a condition tree in the CONDITION BUILDER's own
|
||||||
|
// words. § Observability's claim is that `gte` says "is at least" on the
|
||||||
|
// diagnosis panel because it says "is at least" in the rule editor. That is
|
||||||
|
// a claim about two files agreeing, so it is tested against the grammar's
|
||||||
|
// own labels rather than against a string this file wrote down.
|
||||||
|
// • `observe()`, whose whole reason for existing is that a firing between two
|
||||||
|
// ticks is not observable from either of them — and whose near-miss branch
|
||||||
|
// is the more valuable of its two outcomes on the night.
|
||||||
|
//
|
||||||
|
// **`describe()` is tested for what it does NOT say as much as what it does.**
|
||||||
|
// An `after` gate is never stalled, however long it was authored to wait: a
|
||||||
|
// phase waiting out six hours it was told to wait is working, and health that
|
||||||
|
// said otherwise would train an operator to ignore it.
|
||||||
|
//
|
||||||
|
// Point the DB at a closed port before requiring anything.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const gates = require('../src/events/gates')
|
||||||
|
const conditions = require('../src/engagement/conditions')
|
||||||
|
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||||
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const T0 = new Date('2026-09-02T20:31:04Z')
|
||||||
|
|
||||||
|
// ── phrase(): the words are the grammar's, not this file's ─────────────────
|
||||||
|
|
||||||
|
test('every operator renders with the label the condition grammar declares', () => {
|
||||||
|
// Not a table of expected strings: that would be a second copy of the labels,
|
||||||
|
// and the point of rendering server-side is that there is only one.
|
||||||
|
for (const [cmp, operator] of Object.entries(conditions.OPERATORS)) {
|
||||||
|
const leaf =
|
||||||
|
operator.arity === 0
|
||||||
|
? { variable: 'region', cmp }
|
||||||
|
: operator.arity === 'list'
|
||||||
|
? { variable: 'region', cmp, value: ['Yew', 'Britain'] }
|
||||||
|
: { variable: 'region', cmp, value: 'Yew' }
|
||||||
|
const rendered = gates.phrase(leaf)
|
||||||
|
assert.ok(rendered.startsWith(`region ${operator.label}`), `${cmp} should read as "${operator.label}"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a tree reads as a sentence, and only brackets where the reading changes', () => {
|
||||||
|
assert.equal(gates.phrase({ variable: 'region', cmp: 'eq', value: 'Yew' }), 'region is "Yew"')
|
||||||
|
assert.equal(
|
||||||
|
gates.phrase({ op: 'and', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }, { variable: 'level', cmp: 'gte', value: 3 }] }),
|
||||||
|
'region is "Yew" and level is at least 3',
|
||||||
|
'a flat and is a sentence, not a nest of brackets',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
gates.phrase({
|
||||||
|
op: 'or',
|
||||||
|
nodes: [
|
||||||
|
{ op: 'and', nodes: [{ variable: 'a', cmp: 'present' }, { variable: 'b', cmp: 'in', value: ['x', 'y'] }] },
|
||||||
|
{ variable: 'c', cmp: 'absent' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'(a is present and b is one of "x", "y") or c is absent',
|
||||||
|
)
|
||||||
|
assert.equal(gates.phrase({ op: 'not', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }] }), 'not (region is "Yew")')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no conditions renders as nothing, not as "always true"', () => {
|
||||||
|
// The caller says "on any firing of this trigger"; a clause claiming
|
||||||
|
// everything is true is one more thing to read past.
|
||||||
|
assert.equal(gates.phrase(null), null)
|
||||||
|
assert.equal(gates.phrase({ variable: 'x', cmp: 'nonsense', value: 1 }), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('variablesIn names each variable once, in the order the tree names them', () => {
|
||||||
|
const tree = {
|
||||||
|
op: 'and',
|
||||||
|
nodes: [
|
||||||
|
{ variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||||
|
{ op: 'or', nodes: [{ variable: 'level', cmp: 'gte', value: 3 }, { variable: 'region', cmp: 'ne', value: 'Britain' }] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert.deepEqual(gates.variablesIn(tree), ['region', 'level'])
|
||||||
|
assert.deepEqual(gates.variablesIn(null), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── describe(): what the panel shows ───────────────────────────────────────
|
||||||
|
|
||||||
|
const gateRow = (over = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
run_id: 1,
|
||||||
|
phase: 'boss',
|
||||||
|
kind: 'on',
|
||||||
|
after_seconds: null,
|
||||||
|
trigger_id: 'uo.champ.boss_up',
|
||||||
|
conditions: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||||
|
needed: 1,
|
||||||
|
tally: 0,
|
||||||
|
entered_at: T0,
|
||||||
|
due_at: null,
|
||||||
|
last_event: null,
|
||||||
|
last_event_at: null,
|
||||||
|
satisfied_at: null,
|
||||||
|
satisfied_by: null,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the panel answers "why didn\'t phase 3 start?" with the tally, the clock and the clause', () => {
|
||||||
|
const at = new Date(T0.getTime() + 28 * 60_000)
|
||||||
|
const described = gates.describe(gateRow(), at)
|
||||||
|
assert.equal(described.phase, 'boss')
|
||||||
|
assert.equal(described.waitingOn, 'uo.champ.boss_up')
|
||||||
|
assert.equal(described.where, 'region is "Yew"')
|
||||||
|
assert.equal(described.seen, 0)
|
||||||
|
assert.equal(described.needed, 1)
|
||||||
|
assert.equal(described.elapsedSeconds, 28 * 60)
|
||||||
|
assert.equal(described.satisfied, false)
|
||||||
|
assert.equal(described.stalled, false, '28 minutes is not yet a stall')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a satisfied gate stops its clock at the moment it was satisfied', () => {
|
||||||
|
// Live, `elapsedSeconds` 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 found it disagreeing with
|
||||||
|
// `phase.advanced`'s own `waitedSeconds` by the age of the open screen.
|
||||||
|
const gate = gateRow({ satisfied_at: new Date(T0.getTime() + 121_000), satisfied_by: 'forced' })
|
||||||
|
const muchLater = new Date(T0.getTime() + 3 * 3600_000)
|
||||||
|
assert.equal(gates.describe(gate, muchLater).elapsedSeconds, 121)
|
||||||
|
// And an open one still measures to now.
|
||||||
|
assert.equal(gates.describe(gateRow(), new Date(T0.getTime() + 300_000)).elapsedSeconds, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `after` gate is never stalled, however long it was told to wait', () => {
|
||||||
|
const gate = gateRow({
|
||||||
|
kind: 'after',
|
||||||
|
trigger_id: null,
|
||||||
|
conditions: null,
|
||||||
|
after_seconds: 6 * 3600,
|
||||||
|
due_at: new Date(T0.getTime() + 6 * 3600_000),
|
||||||
|
})
|
||||||
|
const described = gates.describe(gate, new Date(T0.getTime() + gates.STALL_MS * 4))
|
||||||
|
assert.equal(described.stalled, false)
|
||||||
|
assert.equal(described.after, 6 * 3600)
|
||||||
|
assert.equal(described.waitingOn, undefined, 'and it carries none of the `on` fields')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `on` gate is stalled once it has waited past the threshold, and never once satisfied', () => {
|
||||||
|
const late = new Date(T0.getTime() + gates.STALL_MS + 1000)
|
||||||
|
assert.equal(gates.describe(gateRow(), late).stalled, true)
|
||||||
|
assert.equal(gates.describe(gateRow({ satisfied_at: T0, satisfied_by: 'condition' }), late).stalled, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── observe(): the emit path ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
let store
|
||||||
|
|
||||||
|
function installStubs() {
|
||||||
|
store = { gates: [], log: [] }
|
||||||
|
gatesDb.openForTrigger = async (triggerId) => store.gates.filter((g) => g.trigger_id === triggerId && !g.satisfied_at)
|
||||||
|
gatesDb.count = async (id, { lastEvent, now }) => {
|
||||||
|
const g = store.gates.find((x) => x.id === id)
|
||||||
|
if (!g || g.satisfied_at) return { counted: false, satisfied: false }
|
||||||
|
g.tally += 1
|
||||||
|
g.last_event = lastEvent
|
||||||
|
g.last_event_at = now
|
||||||
|
if (g.tally >= g.needed) g.satisfied_at = now
|
||||||
|
return { counted: true, satisfied: Boolean(g.satisfied_at), tally: g.tally }
|
||||||
|
}
|
||||||
|
gatesDb.noteNearMiss = async (id, { lastEvent, now }) => {
|
||||||
|
const g = store.gates.find((x) => x.id === id)
|
||||||
|
if (!g) return false
|
||||||
|
g.last_event = lastEvent
|
||||||
|
g.last_event_at = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
logDb.write = async (line) => {
|
||||||
|
store.log.push(line)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = [
|
||||||
|
[gatesDb, { ...gatesDb }],
|
||||||
|
[logDb, { ...logDb }],
|
||||||
|
]
|
||||||
|
beforeEach(installStubs)
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [mod, fns] of originals) Object.assign(mod, fns)
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = (data, triggerId = 'uo.champ.boss_up') =>
|
||||||
|
gates.observe({ triggerId, occurredAt: T0.toISOString(), subject: 'champ:yew', data })
|
||||||
|
|
||||||
|
test('a matching firing counts, and a near miss is recorded without counting', async () => {
|
||||||
|
store.gates.push(gateRow({ needed: 2 }))
|
||||||
|
|
||||||
|
await emit({ region: 'Britain', level: 4 })
|
||||||
|
assert.equal(store.gates[0].tally, 0)
|
||||||
|
assert.equal(store.gates[0].last_event.matched, false)
|
||||||
|
|
||||||
|
await emit({ region: 'Yew', level: 4 })
|
||||||
|
assert.equal(store.gates[0].tally, 1)
|
||||||
|
assert.equal(store.gates[0].satisfied_at, null)
|
||||||
|
|
||||||
|
await emit({ region: 'Yew', level: 1 })
|
||||||
|
assert.equal(store.gates[0].tally, 2)
|
||||||
|
assert.ok(store.gates[0].satisfied_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only the variables the condition names are recorded, never the payload', async () => {
|
||||||
|
// The 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.
|
||||||
|
store.gates.push(gateRow())
|
||||||
|
await emit({ region: 'Yew', playerName: 'Dupre', houseLocation: '1423,1712', level: 4 })
|
||||||
|
|
||||||
|
assert.deepEqual(store.gates[0].last_event.variables, { region: 'Yew' })
|
||||||
|
const line = store.log.find((l) => l.kind === 'condition.evaluated')
|
||||||
|
assert.deepEqual(line.detail.variables, { region: 'Yew' })
|
||||||
|
assert.equal(JSON.stringify(store.log).includes('Dupre'), false)
|
||||||
|
assert.equal(JSON.stringify(store.gates).includes('1423,1712'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('both outcomes are logged, with the tally the row actually holds', async () => {
|
||||||
|
store.gates.push(gateRow({ needed: 2 }))
|
||||||
|
await emit({ region: 'Britain' })
|
||||||
|
await emit({ region: 'Yew' })
|
||||||
|
|
||||||
|
const lines = store.log.filter((l) => l.kind === 'condition.evaluated')
|
||||||
|
assert.equal(lines.length, 2)
|
||||||
|
assert.deepEqual(lines.map((l) => l.detail.matched), [false, true])
|
||||||
|
assert.deepEqual(lines.map((l) => l.detail.seen), [0, 1])
|
||||||
|
assert.deepEqual(lines.map((l) => l.detail.satisfied), [false, false])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate with no `where` counts every firing of its trigger', async () => {
|
||||||
|
store.gates.push(gateRow({ conditions: null }))
|
||||||
|
await emit({ anything: true })
|
||||||
|
assert.equal(store.gates[0].tally, 1)
|
||||||
|
assert.deepEqual(store.gates[0].last_event.variables, {}, 'and there are no named variables to record')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a firing of another trigger touches nothing', async () => {
|
||||||
|
store.gates.push(gateRow())
|
||||||
|
const summary = await emit({ region: 'Yew' }, 'uo.champ.started')
|
||||||
|
assert.equal(summary.gates, 0)
|
||||||
|
assert.equal(store.gates[0].tally, 0)
|
||||||
|
assert.equal(store.log.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('observe never rejects, however badly the database is behaving', async () => {
|
||||||
|
// It is called from inside a game-event handler by way of `ctx.events.emit`,
|
||||||
|
// exactly as `engine.dispatch` is. A database problem of core's must not
|
||||||
|
// become a module's control flow at three in the morning.
|
||||||
|
gatesDb.openForTrigger = async () => {
|
||||||
|
throw new Error('pool exhausted')
|
||||||
|
}
|
||||||
|
const summary = await emit({ region: 'Yew' })
|
||||||
|
assert.deepEqual(summary, { gates: 0, counted: 0, satisfied: 0 })
|
||||||
|
})
|
||||||
311
server/test/eventIntegrations.test.js
Normal file
311
server/test/eventIntegrations.test.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
// ── The integrations (EVENTS_PLAN.md Phase 10) ─────────────────────────────
|
||||||
|
//
|
||||||
|
// §J's three reuse claims, each turned into something that fails when the reuse
|
||||||
|
// stops being real:
|
||||||
|
//
|
||||||
|
// • **`core.announce.post` reuses the announce legs** rather than growing a
|
||||||
|
// second delivery pipeline — so the assertions are about what it puts in
|
||||||
|
// `announce_jobs`, not about what reaches Discord.
|
||||||
|
// • **an event's job must not stand on the news pipeline's toes.** A post may
|
||||||
|
// now have two jobs, and everything that reads "the post's job" — the admin
|
||||||
|
// panel, its retry button, `announced_at` — must still mean the news one.
|
||||||
|
// This is the half that would be silent: nothing errors, the panel just
|
||||||
|
// starts showing a different row.
|
||||||
|
// • **`core.results.publish` is idempotent**, because it is an ordinary step
|
||||||
|
// an author may place twice and the runner may retry.
|
||||||
|
//
|
||||||
|
// And the two seeded rules, checked against the declarations they name. A seeded
|
||||||
|
// rule pointing at a template that does not exist, or at an audience its own
|
||||||
|
// trigger's ceiling forbids, is a rule that fails on the first firing after an
|
||||||
|
// operator switches it on — which is the worst possible moment to find out.
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const registries = require('../src/modules/registries')
|
||||||
|
const ceilings = require('../src/modules/ceilings')
|
||||||
|
const coreRules = require('../src/engagement/coreRules')
|
||||||
|
const templateSeeds = require('../src/engagement/templateSeeds')
|
||||||
|
const { TRIGGERS } = require('../src/config/coreTriggers')
|
||||||
|
const postsDb = require('../src/model/posts/posts.db')
|
||||||
|
const postsModel = require('../src/model/posts/posts.model')
|
||||||
|
const announceDb = require('../src/model/announceJobs/announceJobs.db')
|
||||||
|
const announceModel = require('../src/model/announceJobs/announceJobs.model')
|
||||||
|
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||||
|
const runsDb = require('../src/model/events/eventRuns.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
// ── core.announce.post ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POSTS = {
|
||||||
|
1: { id: 1, category: 'news', title: 'The Yew Invasion', published: 1, announce_job_id: 9 },
|
||||||
|
2: { id: 2, category: 'news', title: 'A draft', published: 0, announce_job_id: null },
|
||||||
|
3: { id: 3, category: 'newsletter', title: 'Never announced', published: 1, announce_job_id: null },
|
||||||
|
}
|
||||||
|
|
||||||
|
let enqueued
|
||||||
|
let linked
|
||||||
|
const originals = {
|
||||||
|
getById: postsModel.getById,
|
||||||
|
link: postsModel.linkAnnounceJob,
|
||||||
|
create: announceDb.create,
|
||||||
|
legIds: registries.announceLegIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registries._reset()
|
||||||
|
registries.registerCore()
|
||||||
|
enqueued = []
|
||||||
|
linked = []
|
||||||
|
postsModel.getById = async (id) => POSTS[id] || null
|
||||||
|
postsModel.linkAnnounceJob = async (id, jobId) => {
|
||||||
|
linked.push({ id, jobId })
|
||||||
|
}
|
||||||
|
announceDb.create = async (postId, legs, opts = {}) => {
|
||||||
|
enqueued.push({ postId, legs, runId: opts.runId ?? null })
|
||||||
|
return 100 + enqueued.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
postsModel.getById = originals.getById
|
||||||
|
postsModel.linkAnnounceJob = originals.link
|
||||||
|
announceDb.create = originals.create
|
||||||
|
registries.announceLegIds = originals.legIds
|
||||||
|
})
|
||||||
|
|
||||||
|
const announcePost = () => registries.eventAction('core.announce.post')
|
||||||
|
|
||||||
|
test('a published post is queued on every registered leg, tagged with the run', async () => {
|
||||||
|
const answer = await announcePost().perform({ runId: 3692, params: { postId: 1 }, verify: false })
|
||||||
|
assert.deepEqual(answer, { ok: true })
|
||||||
|
assert.equal(enqueued.length, 1)
|
||||||
|
assert.equal(enqueued[0].postId, 1)
|
||||||
|
assert.equal(enqueued[0].runId, 3692)
|
||||||
|
// The legs come from the registry, so a module's leg is included without this
|
||||||
|
// action naming one — which is the whole of "reuse the legs".
|
||||||
|
assert.deepEqual(enqueued[0].legs, registries.announceLegIds())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unpublished post is refused, and refused terminally', async () => {
|
||||||
|
// 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. Publishing is the CMS's decision and this action is not it.
|
||||||
|
const answer = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: false })
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.retry, false)
|
||||||
|
assert.match(answer.error, /not published/)
|
||||||
|
assert.equal(enqueued.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a post id that names nothing is refused terminally — it will not appear in sixty seconds', async () => {
|
||||||
|
for (const bad of [999, 0, -1, 'twelve', null]) {
|
||||||
|
const answer = await announcePost().perform({ runId: 1, params: { postId: bad }, verify: false })
|
||||||
|
assert.equal(answer.ok, false, `${bad}`)
|
||||||
|
assert.equal(answer.retry, false, `${bad}`)
|
||||||
|
}
|
||||||
|
assert.equal(enqueued.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run checks the post and queues nothing', async () => {
|
||||||
|
const answer = await announcePost().perform({ runId: 1, params: { postId: 1 }, verify: true })
|
||||||
|
assert.deepEqual(answer, { ok: true })
|
||||||
|
assert.equal(enqueued.length, 0)
|
||||||
|
|
||||||
|
// …and still refuses what the real run would refuse, which is the point of a
|
||||||
|
// dry run: the cost of finding out is a page, not a half-changed world.
|
||||||
|
const refused = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: true })
|
||||||
|
assert.equal(refused.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the post\'s back-pointer is left alone when it already has one', async () => {
|
||||||
|
// `announce_job_id` is what the post admin panel reads and what
|
||||||
|
// `shouldEnqueue` guards on. Moving it to an event's job would make a
|
||||||
|
// re-published post announce itself again.
|
||||||
|
await announceModel.enqueueForRun(1, 3692)
|
||||||
|
assert.deepEqual(linked, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a post that has never been announced gains the pointer, because this IS its announcement', async () => {
|
||||||
|
await announceModel.enqueueForRun(3, 3692)
|
||||||
|
assert.equal(linked.length, 1)
|
||||||
|
assert.equal(linked[0].id, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the news pipeline is untouched ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('"the post\'s job" still means the news one, however many an event has added', async () => {
|
||||||
|
// The half that would be silent: nothing errors, the admin panel just starts
|
||||||
|
// rendering an event's job and its retry button retries that instead.
|
||||||
|
//
|
||||||
|
// Asserted against the query's own text rather than by stubbing the pool.
|
||||||
|
// `announceJobs.db.js` destructures `query` at require time, so a stub on the
|
||||||
|
// db module here would replace something nothing reads — and the test would
|
||||||
|
// then pass whatever the SQL said, which is the one thing it exists to check.
|
||||||
|
const source = require('node:fs').readFileSync(
|
||||||
|
require.resolve('../src/model/announceJobs/announceJobs.db'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const fn = source.slice(source.indexOf('async function findByPostId'))
|
||||||
|
assert.match(fn.slice(0, fn.indexOf('\n}')), /run_id IS NULL/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run\'s job does not restamp the post\'s announced_at', async () => {
|
||||||
|
// `announced_at` means "when this post was announced". An event linking a
|
||||||
|
// three-week-old article would otherwise rewrite that to today, and the admin
|
||||||
|
// panel would report a publication date the post does not have.
|
||||||
|
const stamped = []
|
||||||
|
const saved = { findById: announceDb.findById, setStatus: announceDb.setStatus, markAnnounced: postsModel.markAnnounced }
|
||||||
|
announceDb.findById = async (id) => ({
|
||||||
|
id,
|
||||||
|
post_id: 1,
|
||||||
|
run_id: id === 1 ? null : 3692,
|
||||||
|
status: 'pending',
|
||||||
|
legs: [{ leg: 'discord', status: 'done' }],
|
||||||
|
})
|
||||||
|
announceDb.setStatus = async () => {}
|
||||||
|
postsModel.markAnnounced = async (postId) => {
|
||||||
|
stamped.push(postId)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await announceModel.refreshStatus(2) // a run's job
|
||||||
|
assert.deepEqual(stamped, [])
|
||||||
|
await announceModel.refreshStatus(1) // the news pipeline's own
|
||||||
|
assert.deepEqual(stamped, [1])
|
||||||
|
} finally {
|
||||||
|
Object.assign(announceDb, { findById: saved.findById, setStatus: saved.setStatus })
|
||||||
|
postsModel.markAnnounced = saved.markAnnounced
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── core.results.publish ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('publishing ranks the run\'s participants and stamps it, and does neither on a dry run', async () => {
|
||||||
|
const calls = []
|
||||||
|
const saved = { rank: participantsDb.rankRun, mark: runsDb.markResultsPublished }
|
||||||
|
participantsDb.rankRun = async (id) => {
|
||||||
|
calls.push(['rank', id])
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
runsDb.markResultsPublished = async (id) => {
|
||||||
|
calls.push(['stamp', id])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const action = registries.eventAction('core.results.publish')
|
||||||
|
assert.deepEqual(await action.perform({ runId: 3692, verify: true }), { ok: true })
|
||||||
|
assert.deepEqual(calls, [])
|
||||||
|
|
||||||
|
assert.deepEqual(await action.perform({ runId: 3692, verify: false }), { ok: true })
|
||||||
|
assert.deepEqual(calls, [['rank', 3692], ['stamp', 3692]])
|
||||||
|
|
||||||
|
// Idempotent by construction: the ranking is a total order over
|
||||||
|
// `(score, joined_at, id)`, so a second publication writes the same numbers.
|
||||||
|
// That is what makes it safe as an ordinary retried step.
|
||||||
|
await action.perform({ runId: 3692, verify: false })
|
||||||
|
assert.equal(calls.length, 4)
|
||||||
|
} finally {
|
||||||
|
participantsDb.rankRun = saved.rank
|
||||||
|
runsDb.markResultsPublished = saved.mark
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('publishing takes no params — a run may not publish somebody else\'s results', async () => {
|
||||||
|
assert.deepEqual(registries.eventAction('core.results.publish').params, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('publishing is `inspect`, so it is default-ON and an author can place it unaided', () => {
|
||||||
|
// Nothing in the game world changes and nobody is messaged: a table core
|
||||||
|
// already holds becomes readable.
|
||||||
|
const action = registries.eventAction('core.results.publish')
|
||||||
|
assert.equal(action.risk, 'inspect')
|
||||||
|
assert.equal(action.reversible, 'none')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the option source ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the posts option source answers value/label/group from published posts only', async () => {
|
||||||
|
const saved = postsDb.listPublishedForOptions
|
||||||
|
postsDb.listPublishedForOptions = async () => [
|
||||||
|
{ id: 1, category: 'news', title: 'The Yew Invasion' },
|
||||||
|
{ id: 3, category: 'newsletter', title: 'Never announced' },
|
||||||
|
]
|
||||||
|
try {
|
||||||
|
// Through the resolver the catalog route uses, so the normalisation it
|
||||||
|
// applies — every value stringified for the `<select>` — is exercised too
|
||||||
|
// rather than only the raw `resolve()`.
|
||||||
|
const answer = await registries.resolveOptionSource('core.options.posts')
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
assert.deepEqual(answer.options, [
|
||||||
|
{ value: '1', label: 'The Yew Invasion', group: 'news' },
|
||||||
|
{ value: '3', label: 'Never announced', group: 'newsletter' },
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
postsDb.listPublishedForOptions = saved
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the seeded rules ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const declared = (id) => TRIGGERS.find((t) => t.id === id)
|
||||||
|
const seededKeys = new Set(templateSeeds.SEEDS.map((s) => s.key))
|
||||||
|
|
||||||
|
test('two rules are seeded, both for triggers core declares', () => {
|
||||||
|
assert.equal(coreRules.EVENT_RULES.length, 2)
|
||||||
|
for (const rule of coreRules.EVENT_RULES) {
|
||||||
|
assert.ok(declared(rule.trigger_id), `${rule.trigger_id} is not declared`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every seeded rule names templates that exist', () => {
|
||||||
|
// A rule pointing at a template that does not exist fails on the first firing
|
||||||
|
// after an operator switches it on, which is the worst moment to find out.
|
||||||
|
for (const rule of coreRules.EVENT_RULES) {
|
||||||
|
for (const [channel, key] of Object.entries(rule.template_keys)) {
|
||||||
|
assert.ok(seededKeys.has(key), `${rule.trigger_id}.${channel} names "${key}", which is not seeded`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no seeded rule asks for an audience its trigger\'s ceiling forbids', () => {
|
||||||
|
for (const rule of coreRules.EVENT_RULES) {
|
||||||
|
const trigger = declared(rule.trigger_id)
|
||||||
|
assert.ok(
|
||||||
|
ceilings.permits(trigger.ceiling, rule.audience),
|
||||||
|
`${rule.trigger_id} is ceilinged ${trigger.ceiling} and the seeded rule asks for ${rule.audience}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the failure rule stays at admin and carries no push', () => {
|
||||||
|
const failed = coreRules.EVENT_RULES.find((r) => r.trigger_id === 'event.run.failed')
|
||||||
|
assert.equal(failed.audience, 'admin')
|
||||||
|
// 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.
|
||||||
|
assert.ok(!failed.channels.includes('push'))
|
||||||
|
// No cooldown, and it is the one rule here that must not have one: the subject
|
||||||
|
// is the run, so a cooldown would only ever suppress a second failure of the
|
||||||
|
// very run an administrator most needs the second line about.
|
||||||
|
assert.equal(failed.cooldown_seconds, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every seeded rule carries an hourly ceiling — a module may choose the number, not decline one', () => {
|
||||||
|
for (const rule of coreRules.EVENT_RULES) {
|
||||||
|
assert.ok(Number.isInteger(rule.max_sends_per_hour) && rule.max_sends_per_hour > 0, rule.trigger_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the event rules have their OWN one-shot key, so an upgraded deployment still gets them', () => {
|
||||||
|
// The Team key is stamped on every deployment that has booted since Phase 6
|
||||||
|
// and the news key on every one since Phase 11. Appending to either list would
|
||||||
|
// seed these on fresh installs only, and on exactly the upgrades that want
|
||||||
|
// them, never.
|
||||||
|
const keys = [coreRules.SEEDED_KEY, coreRules.NEWS_SEEDED_KEY, coreRules.EVENT_SEEDED_KEY]
|
||||||
|
assert.equal(new Set(keys).size, 3)
|
||||||
|
})
|
||||||
337
server/test/eventLedger.test.js
Normal file
337
server/test/eventLedger.test.js
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
// ── The resource ledger's write half (EVENTS_PLAN.md Phase 8) ──────────────
|
||||||
|
//
|
||||||
|
// §D's two rules, and rule 1 is the one this file exists for: **a resource is
|
||||||
|
// recorded BEFORE it is confirmed.** The obstacle it works around is that a
|
||||||
|
// spawn's serial does not exist until the module answers, so what goes in before
|
||||||
|
// the dispatch is a placeholder keyed by the step's idempotency key — and the
|
||||||
|
// property worth a test is that the placeholder SURVIVES an answer that never
|
||||||
|
// comes, because that is the case where recording afterwards would have lost the
|
||||||
|
// object for ever.
|
||||||
|
//
|
||||||
|
// The other rules here are about what core will and will not write down on a
|
||||||
|
// module's say-so. Every one of them is fail-closed in a specific direction:
|
||||||
|
//
|
||||||
|
// • the reserved `@step` kind is core's and a module may not claim it
|
||||||
|
// • an `override` must name a lease core knows how to give back, or core would
|
||||||
|
// be recording something it has no way to restore
|
||||||
|
// • a duplicate is "already recorded", not an error — a retry re-sends the same
|
||||||
|
// idempotency key and a module may honestly report the same resources twice
|
||||||
|
// • a badly shaped resource is dropped and LOGGED, never a failed step: the
|
||||||
|
// step changed the world, and turning bookkeeping into a retry would re-run
|
||||||
|
// a world write that already happened
|
||||||
|
//
|
||||||
|
// The db layer is stubbed with a store that enforces `uq_evres_target`, because
|
||||||
|
// that refusal is behaviour the callers branch on rather than an implementation
|
||||||
|
// detail. The SQL itself is `eventRunnerSql.test.js`'s, against a real MariaDB.
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const registries = require('../src/modules/registries')
|
||||||
|
const ledger = require('../src/events/ledger')
|
||||||
|
const resourcesDb = require('../src/model/events/eventRunResources.db')
|
||||||
|
const runsDb = require('../src/model/events/eventRuns.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const HELD = ['pending', 'confirmed', 'reverting']
|
||||||
|
|
||||||
|
let store
|
||||||
|
const originals = { resourcesDb: { ...resourcesDb }, runsDb: { ...runsDb } }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registries._reset()
|
||||||
|
store = { rows: new Map(), next: 1, cleanupStatus: 'not_required' }
|
||||||
|
|
||||||
|
resourcesDb.reserve = async ({ runId, stepId = null, owner, kind, ref, payload = null, leaseUntil = null, memberKey = null }) => {
|
||||||
|
const holder = [...store.rows.values()].find(
|
||||||
|
(r) => r.owner_module === owner && r.kind === kind && r.ref === ref && HELD.includes(r.status),
|
||||||
|
)
|
||||||
|
if (holder) return { ok: false, code: 'held', holder: { run_id: holder.run_id, status: holder.status } }
|
||||||
|
const id = store.next++
|
||||||
|
store.rows.set(id, {
|
||||||
|
id,
|
||||||
|
run_id: runId,
|
||||||
|
step_id: stepId,
|
||||||
|
owner_module: owner,
|
||||||
|
kind,
|
||||||
|
ref,
|
||||||
|
payload,
|
||||||
|
lease_until: leaseUntil,
|
||||||
|
status: 'pending',
|
||||||
|
revert_attempts: 0,
|
||||||
|
last_error: null,
|
||||||
|
member_key: memberKey,
|
||||||
|
})
|
||||||
|
return { ok: true, id }
|
||||||
|
}
|
||||||
|
resourcesDb.confirm = async (id) => {
|
||||||
|
const r = store.rows.get(id)
|
||||||
|
if (!r || r.status !== 'pending') return false
|
||||||
|
r.status = 'confirmed'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
resourcesDb.resolvePlaceholder = async (id) => {
|
||||||
|
const r = store.rows.get(id)
|
||||||
|
if (!r || r.kind !== resourcesDb.STEP_KIND) return false
|
||||||
|
r.status = 'reverted'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
resourcesDb.findByTarget = async (owner, kind, ref) =>
|
||||||
|
[...store.rows.values()].reverse().find((r) => r.owner_module === owner && r.kind === kind && r.ref === ref) || null
|
||||||
|
|
||||||
|
runsDb.setCleanupStatus = async (id, to, from = null) => {
|
||||||
|
if (from && !from.includes(store.cleanupStatus)) return false
|
||||||
|
store.cleanupStatus = to
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(resourcesDb, originals.resourcesDb)
|
||||||
|
Object.assign(runsDb, originals.runsDb)
|
||||||
|
registries._reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
const RUN = { id: 7 }
|
||||||
|
const STEP = { id: 42, phase: 'invasion', seq: 0, idempotency_key: 'a'.repeat(40) }
|
||||||
|
|
||||||
|
const action = (over = {}) => ({
|
||||||
|
id: 'demo.spawn',
|
||||||
|
owner: 'demo',
|
||||||
|
label: 'Spawn',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
budgetMs: 1000,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = () => [...store.rows.values()]
|
||||||
|
|
||||||
|
// ── Which actions ledger at all ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('only the two reversible classes core has to come back for are ledgered', () => {
|
||||||
|
// `none` is gone once done, `self` undoes itself. Neither has anything core
|
||||||
|
// could revert, and giving one a placeholder would put a row in the ledger that
|
||||||
|
// teardown could never resolve — the exact reason `core.announce` is declared
|
||||||
|
// `none` rather than `ledger`.
|
||||||
|
assert.equal(ledger.ledgers(action({ reversible: 'ledger' })), true)
|
||||||
|
assert.equal(ledger.ledgers(action({ reversible: 'override' })), true)
|
||||||
|
assert.equal(ledger.ledgers(action({ reversible: 'none' })), false)
|
||||||
|
assert.equal(ledger.ledgers(action({ reversible: 'self' })), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only a ledger action gets a placeholder; an override reserves its own target', async () => {
|
||||||
|
// The asymmetry is the design. A spawn's ref is unknown until the module
|
||||||
|
// answers, so the placeholder stands in for it; a lease's target is the lease
|
||||||
|
// id the step already names, so `core.lease` writes the real row before it
|
||||||
|
// touches the world — which is rule 1 in a stronger form, and the only place
|
||||||
|
// the two-events-one-target refusal can happen before the world has changed.
|
||||||
|
assert.equal(typeof (await ledger.reserveStep(RUN, STEP, action())), 'number')
|
||||||
|
assert.equal(await ledger.reserveStep(RUN, STEP, action({ id: 'demo.b', reversible: 'override' })), null)
|
||||||
|
assert.equal(await ledger.reserveStep(RUN, STEP, action({ id: 'demo.c', reversible: 'none' })), null)
|
||||||
|
assert.equal(rows().length, 1)
|
||||||
|
assert.equal(rows()[0].kind, '@step')
|
||||||
|
assert.equal(rows()[0].ref, STEP.idempotency_key)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the first ledger row is what makes a run dirty, and only from not_required', async () => {
|
||||||
|
assert.equal(store.cleanupStatus, 'not_required')
|
||||||
|
await ledger.reserveStep(RUN, STEP, action())
|
||||||
|
assert.equal(store.cleanupStatus, 'pending')
|
||||||
|
|
||||||
|
// A run whose sweep has already finished must not be walked back to `pending`
|
||||||
|
// by a late row: only a human's cleanup re-opens it, and it does so
|
||||||
|
// deliberately and with an actor on the log line.
|
||||||
|
store.cleanupStatus = 'complete'
|
||||||
|
await ledger.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: { ...STEP, id: 43, idempotency_key: 'b'.repeat(40) },
|
||||||
|
action: action(),
|
||||||
|
placeholderId: null,
|
||||||
|
resources: [{ kind: 'creature', ref: '0x1' }],
|
||||||
|
})
|
||||||
|
assert.equal(store.cleanupStatus, 'complete')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rule 1 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a lost acknowledgement leaves the placeholder standing, which is the whole point', async () => {
|
||||||
|
const placeholderId = await ledger.reserveStep(RUN, STEP, action())
|
||||||
|
// The dispatch timed out: no answer, so `recordAnswer` is never reached. This
|
||||||
|
// is the case rule 1 exists for — record afterwards and the object the module
|
||||||
|
// may well have created is invisible to cleanup for ever.
|
||||||
|
assert.equal(rows()[0].status, 'pending')
|
||||||
|
assert.equal(rows()[0].payload.action, 'demo.spawn')
|
||||||
|
assert.ok(placeholderId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a retry reuses its own placeholder rather than writing a second', async () => {
|
||||||
|
// An idempotency key is minted once per step and does not vary by attempt (§E),
|
||||||
|
// so the second attempt's insert collides with the first attempt's row. Finding
|
||||||
|
// it already there is the correct answer, and a second row would be a second
|
||||||
|
// thing for cleanup to revert.
|
||||||
|
const first = await ledger.reserveStep(RUN, STEP, action())
|
||||||
|
const second = await ledger.reserveStep(RUN, STEP, action())
|
||||||
|
assert.equal(first, second)
|
||||||
|
assert.equal(rows().length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the placeholder is resolved once the real rows exist', async () => {
|
||||||
|
const placeholderId = await ledger.reserveStep(RUN, STEP, action())
|
||||||
|
const out = await ledger.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: STEP,
|
||||||
|
action: action(),
|
||||||
|
placeholderId,
|
||||||
|
resources: [
|
||||||
|
{ kind: 'creature', ref: '0x40001234' },
|
||||||
|
{ kind: 'creature', ref: '0x40001235' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(out.recorded, 2)
|
||||||
|
assert.deepEqual(out.rejected, [])
|
||||||
|
assert.equal(store.rows.get(placeholderId).status, 'reverted')
|
||||||
|
assert.deepEqual(
|
||||||
|
rows().filter((r) => r.kind === 'creature').map((r) => [r.ref, r.status]),
|
||||||
|
[['0x40001234', 'confirmed'], ['0x40001235', 'confirmed']],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action that ledgers and reports nothing still resolves its placeholder', async () => {
|
||||||
|
// "I made nothing" is a real answer. Holding the placeholder open for it would
|
||||||
|
// make cleanup call `revert()` on every terminal path, for ever, for a step that
|
||||||
|
// has nothing to give back.
|
||||||
|
const placeholderId = await ledger.reserveStep(RUN, STEP, action())
|
||||||
|
const out = await ledger.recordAnswer({ run: RUN, step: STEP, action: action(), placeholderId, resources: [] })
|
||||||
|
assert.equal(out.recorded, 0)
|
||||||
|
assert.equal(store.rows.get(placeholderId).status, 'reverted')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module reporting the same resources twice produces one row', async () => {
|
||||||
|
// The database is what makes recording idempotent: `uq_evres_target` refuses
|
||||||
|
// the second insert and this file reads that as "already recorded". Without it
|
||||||
|
// a retry against a module that honestly re-reports its work would double every
|
||||||
|
// row cleanup then has to revert.
|
||||||
|
const args = { run: RUN, step: STEP, action: action(), placeholderId: null, resources: [{ kind: 'creature', ref: '0x1' }] }
|
||||||
|
await ledger.recordAnswer(args)
|
||||||
|
const again = await ledger.recordAnswer(args)
|
||||||
|
assert.equal(again.recorded, 0)
|
||||||
|
assert.deepEqual(again.rejected, [])
|
||||||
|
assert.equal(rows().filter((r) => r.kind === 'creature').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a target another RUN holds is rejected by name rather than silently skipped', async () => {
|
||||||
|
await ledger.recordAnswer({
|
||||||
|
run: { id: 1 },
|
||||||
|
step: STEP,
|
||||||
|
action: action(),
|
||||||
|
placeholderId: null,
|
||||||
|
resources: [{ kind: 'creature', ref: '0x1' }],
|
||||||
|
})
|
||||||
|
const out = await ledger.recordAnswer({
|
||||||
|
run: { id: 2 },
|
||||||
|
step: { ...STEP, id: 99 },
|
||||||
|
action: action(),
|
||||||
|
placeholderId: null,
|
||||||
|
resources: [{ kind: 'creature', ref: '0x1' }],
|
||||||
|
})
|
||||||
|
assert.equal(out.recorded, 0)
|
||||||
|
assert.match(out.rejected.join('\n'), /already held by run 1/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── What core will not write down ──────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a module may not claim core\'s reserved kind', () => {
|
||||||
|
// A module that could write a `@step` row could make its own step's placeholder
|
||||||
|
// look resolved — which is the one row whose survival is the safety property.
|
||||||
|
const bad = ledger.normalise({ kind: '@step', ref: 'x' }, 'demo.spawn')
|
||||||
|
assert.equal(bad.ok, false)
|
||||||
|
assert.match(bad.reason, /reserved kind/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an override 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. Refusing to record it is the fail-closed
|
||||||
|
// direction: rule 2 is a promise core must not make and then break.
|
||||||
|
assert.equal(ledger.normalise({ kind: 'override', ref: 'demo.rate' }, 'demo.x').ok, false)
|
||||||
|
|
||||||
|
const api = registries.stage('demo')
|
||||||
|
api.registerEventLeases([
|
||||||
|
{
|
||||||
|
id: 'demo.rate',
|
||||||
|
label: 'Rate',
|
||||||
|
type: 'float',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
maxDurationMs: 3_600_000,
|
||||||
|
read: async () => ({ ok: true, value: 1 }),
|
||||||
|
apply: async () => ({ ok: true }),
|
||||||
|
restore: async () => ({ ok: true }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
registries.apply(api.staged)
|
||||||
|
assert.equal(ledger.normalise({ kind: 'override', ref: 'demo.rate' }, 'demo.x').ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every bad shape is refused, and none of them is a retry', () => {
|
||||||
|
// A badly shaped resource is the module's mistake rather than the world's, and
|
||||||
|
// it will be just as badly shaped on the second attempt. They are dropped and
|
||||||
|
// reported; the STEP still counts as done, because it is — something happened
|
||||||
|
// in the world, and refusing to record it would be the one outcome worse than
|
||||||
|
// recording it imperfectly.
|
||||||
|
const bad = [
|
||||||
|
null,
|
||||||
|
'a string',
|
||||||
|
['an array'],
|
||||||
|
{ ref: 'x' }, // no kind
|
||||||
|
{ kind: 'creature' }, // no ref
|
||||||
|
{ kind: 'creature', ref: 'x'.repeat(200) },
|
||||||
|
{ kind: 'k'.repeat(80), ref: 'x' },
|
||||||
|
{ kind: 'creature', ref: 'x', memberKey: 'm'.repeat(200) },
|
||||||
|
{ kind: 'creature', ref: 'x', until: 'not a date' },
|
||||||
|
]
|
||||||
|
for (const entry of bad) {
|
||||||
|
assert.equal(ledger.normalise(entry, 'demo.spawn').ok, false, JSON.stringify(entry))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a bad resource never fails the step it came from', async () => {
|
||||||
|
const placeholderId = await ledger.reserveStep(RUN, STEP, action())
|
||||||
|
const out = await ledger.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: STEP,
|
||||||
|
action: action(),
|
||||||
|
placeholderId,
|
||||||
|
resources: [{ kind: 'creature', ref: '0x1' }, { nonsense: true }],
|
||||||
|
})
|
||||||
|
assert.equal(out.recorded, 1)
|
||||||
|
assert.equal(out.rejected.length, 1)
|
||||||
|
// And the placeholder is still resolved: the good row exists, and leaving the
|
||||||
|
// placeholder open would ask the module to undo the step a second time.
|
||||||
|
assert.equal(store.rows.get(placeholderId).status, 'reverted')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lease deadline and a member key ride through verbatim', async () => {
|
||||||
|
const until = new Date('2026-09-04T00:00:00Z')
|
||||||
|
await ledger.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: STEP,
|
||||||
|
action: action(),
|
||||||
|
placeholderId: null,
|
||||||
|
resources: [{ kind: 'reward', ref: 'item-1', memberKey: 'Darrow', until, payload: { cliloc: 1234 } }],
|
||||||
|
})
|
||||||
|
const row = rows()[0]
|
||||||
|
assert.equal(row.member_key, 'Darrow')
|
||||||
|
assert.equal(row.lease_until.getTime(), until.getTime())
|
||||||
|
assert.deepEqual(row.payload, { cliloc: 1234 })
|
||||||
|
// Opaque: core stores what the module said and never interprets it, which is
|
||||||
|
// `ctx.teams.activity.push`'s exact treatment one registry along.
|
||||||
|
assert.equal(row.kind, 'reward')
|
||||||
|
assert.equal(row.owner_module, 'demo')
|
||||||
|
})
|
||||||
990
server/test/eventModuleContract.test.js
Normal file
990
server/test/eventModuleContract.test.js
Normal file
@@ -0,0 +1,990 @@
|
|||||||
|
// ── The module contract, proved with a module (EVENTS_PLAN.md Phase 7) ─────
|
||||||
|
//
|
||||||
|
// §F is the seam this phase opens: `registerEventActions`, `registerEventBudgets`,
|
||||||
|
// `registerEventLeases` and `registerEventOptionSources`, at MODULE_API 1.10.0.
|
||||||
|
// Every one of them was reachable only by `registerCore()` before this phase, and
|
||||||
|
// the plan is explicit about how to prove they are reachable now:
|
||||||
|
//
|
||||||
|
// > **Prove it with a throwaway module, not with module-uo.** A contract
|
||||||
|
// > validated only against the module it was carved out of has not been
|
||||||
|
// > validated, and P9 should be the *second* consumer of this seam.
|
||||||
|
//
|
||||||
|
// So every test below writes a real module to a real directory, points
|
||||||
|
// MODULES_DIR at it and lets the real loader scan, validate, `register()` and
|
||||||
|
// commit it. Nothing here stubs the loader or calls `registries.stage()` by hand:
|
||||||
|
// a test that staged directly would pass just as happily against the code before
|
||||||
|
// this phase, when `buildApi` forwarded none of these four names.
|
||||||
|
//
|
||||||
|
// **The half this file cares most about is the failure half.** §F's load-bearing
|
||||||
|
// envelope rule is that *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 }`, which is `registerTeamProvider`'s default
|
||||||
|
// inverted, because the expensive mistake here is recording a world change that
|
||||||
|
// did not happen. Those five shapes are dispatched from a module below, not
|
||||||
|
// constructed as literals, so what is under test is the path a module actually
|
||||||
|
// takes.
|
||||||
|
//
|
||||||
|
// The pool points at a closed port before anything is required: the loader's
|
||||||
|
// `buildCtx` pulls in the models, which build a mariadb pool at require time. No
|
||||||
|
// query is ever run.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
const registries = require('../src/modules/registries')
|
||||||
|
const dispatch = require('../src/events/dispatch')
|
||||||
|
const authorize = require('../src/events/authorize')
|
||||||
|
const { MODULE_API_VERSION } = require('../src/modules/version')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
let tmpRoot
|
||||||
|
|
||||||
|
const emptyTiers = () => ({
|
||||||
|
public: express.Router(),
|
||||||
|
admin: express.Router(),
|
||||||
|
player: express.Router(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a module and let the real loader scan it.
|
||||||
|
*
|
||||||
|
* MODULES_DIR is read into a const at require time — it has to be, the scan is
|
||||||
|
* synchronous and happens while app.js is being required — so busting the cache
|
||||||
|
* is the only honest way to point the loader somewhere else.
|
||||||
|
*/
|
||||||
|
function loadModule(id, source, manifest = {}) {
|
||||||
|
const dir = path.join(tmpRoot, id)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'module.json'),
|
||||||
|
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.10.0', server: 'index.js', ...manifest }),
|
||||||
|
)
|
||||||
|
fs.writeFileSync(path.join(dir, 'index.js'), source)
|
||||||
|
|
||||||
|
process.env.MODULES_DIR = tmpRoot
|
||||||
|
registries._reset()
|
||||||
|
delete require.cache[require.resolve('../src/modules/loader')]
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const loader = require('../src/modules/loader')
|
||||||
|
loader.load(emptyTiers())
|
||||||
|
return loader.list().find((m) => m.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The state a module that loaded cleanly is in, with its reason if it did not. */
|
||||||
|
const assertRegistered = (record) => {
|
||||||
|
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUN = { id: 1, scope: '' }
|
||||||
|
const step = (actionId, params = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
action_id: actionId,
|
||||||
|
params,
|
||||||
|
idempotency_key: 'k-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The seam is open ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the version a module declares against is 1.10.0', () => {
|
||||||
|
// Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
|
||||||
|
// these tests fail loudly rather than quietly if the bump is ever reverted —
|
||||||
|
// the loader would refuse the manifest and every assertion would become "the
|
||||||
|
// module did not register", which is the same failure the seam closing would
|
||||||
|
// produce. Asserting the number here says which of the two it was.
|
||||||
|
assert.equal(MODULE_API_VERSION, '1.10.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module registers actions, budgets, leases and option sources', () => {
|
||||||
|
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventBudgets([
|
||||||
|
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' },
|
||||||
|
])
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.wisp.summon',
|
||||||
|
label: 'Summon wisps',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
cost: (p) => ({ 'demo.wisps': p.count }),
|
||||||
|
params: [{ name: 'count', type: 'int', required: true, example: 3 }],
|
||||||
|
async perform() { return { ok: true, resources: [{ kind: 'wisp', ref: '0x1' }] } },
|
||||||
|
async revert() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: 'demo.rate.gain',
|
||||||
|
label: 'Gain rate',
|
||||||
|
type: 'float',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
async read() { return { ok: true, value: 1 } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
api.registerEventOptionSources([{
|
||||||
|
id: 'demo.options.hues',
|
||||||
|
label: 'Hues',
|
||||||
|
async resolve() { return [{ value: '1157', label: 'Blood', group: 'Reds' }] },
|
||||||
|
}])
|
||||||
|
}`)
|
||||||
|
|
||||||
|
assertRegistered(record)
|
||||||
|
assert.equal(registries.eventAction('demo.wisp.summon').owner, 'demo')
|
||||||
|
assert.deepEqual(registries.eventBudget('demo.wisps'), {
|
||||||
|
owner: 'demo', id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', description: '',
|
||||||
|
})
|
||||||
|
assert.equal(registries.eventLease('demo.rate.gain').maxDurationMs, 3600000)
|
||||||
|
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['demo.options.hues'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the catalog never carries a callable, whichever registration it came from', async () => {
|
||||||
|
// The rule every registry in this file already keeps, restated for four new
|
||||||
|
// shapes at once: these objects LEAVE THE PROCESS, and the browser's whole
|
||||||
|
// relationship with any of them is naming one by id. A `perform` or a
|
||||||
|
// `resolve` riding out would make §F's "a module registers actions server-side
|
||||||
|
// and adds no routes for them" false in the one direction nobody would notice.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.a', label: 'A', risk: 'notify', reversible: 'none',
|
||||||
|
async perform() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: 'demo.l', label: 'L', type: 'bool', maxDurationMs: 1000,
|
||||||
|
async read() { return { ok: true } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
api.registerEventOptionSources([
|
||||||
|
{ id: 'demo.o', label: 'O', async resolve() { return [] } },
|
||||||
|
])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
for (const a of registries.allEventActions()) {
|
||||||
|
assert.equal(a.perform, undefined)
|
||||||
|
assert.equal(a.revert, undefined)
|
||||||
|
assert.equal(a.cost, undefined)
|
||||||
|
}
|
||||||
|
for (const l of registries.allEventLeases()) {
|
||||||
|
assert.equal(l.read, undefined)
|
||||||
|
assert.equal(l.apply, undefined)
|
||||||
|
assert.equal(l.restore, undefined)
|
||||||
|
assert.equal(l.inForce, undefined)
|
||||||
|
}
|
||||||
|
for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a second call is a module changing its mind, and it fails the module alone', () => {
|
||||||
|
// `once`, on all four, for the reason every batch registration takes it: a
|
||||||
|
// batch is a module's complete statement about what it declares. And the
|
||||||
|
// failure is the loader's §4.4 guarantee — recorded against the module, the
|
||||||
|
// site still up, nothing committed.
|
||||||
|
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventBudgets([{ id: 'demo.a', label: 'A', unit: 'count' }])
|
||||||
|
api.registerEventBudgets([{ id: 'demo.b', label: 'B', unit: 'count' }])
|
||||||
|
}`)
|
||||||
|
assert.equal(record.state, 'startup_failed')
|
||||||
|
assert.match(record.reason, /registerEventBudgets\(\) called twice/)
|
||||||
|
// Validate-then-commit, per registrant: the FIRST batch is gone too.
|
||||||
|
assert.equal(registries.eventBudget('demo.a'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module may not name a budget outside its own prefix', () => {
|
||||||
|
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventBudgets([{ id: 'uo.creatures', label: 'Creatures', unit: 'count' }])
|
||||||
|
}`)
|
||||||
|
assert.equal(record.state, 'startup_failed')
|
||||||
|
assert.match(record.reason, /not namespaced "demo\."/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module that registers nothing at all is a module, not a failure', () => {
|
||||||
|
// §F states it once because it governs every member: everything a module
|
||||||
|
// registers is optional, and core with none of it is still an event engine
|
||||||
|
// that can announce, wait, cue a human and publish results.
|
||||||
|
const record = loadModule('quiet', 'module.exports = () => {}')
|
||||||
|
assertRegistered(record)
|
||||||
|
assert.deepEqual(registries.allEventBudgets(), [])
|
||||||
|
assert.deepEqual(registries.allEventActions().map((a) => a.id), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The envelope: no failure shape reads as success ────────────────────────
|
||||||
|
|
||||||
|
test('every shape a module failure can take is dispatched as a retry, not a success', async () => {
|
||||||
|
// The five §F names, answered by a real module through the real dispatcher.
|
||||||
|
// Written as one module with five verbs rather than five modules, because what
|
||||||
|
// is under test is the CLASSIFIER and a module per shape would be four extra
|
||||||
|
// loader scans saying nothing.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
const verb = (id, perform) => ({
|
||||||
|
id, label: id, risk: 'notify', reversible: 'none', budgetMs: 200, perform,
|
||||||
|
})
|
||||||
|
api.registerEventActions([
|
||||||
|
verb('demo.rejects', async () => { return Promise.reject(new Error('the socket went away')) }),
|
||||||
|
verb('demo.throws', async () => { throw new Error('a typo in the module') }),
|
||||||
|
verb('demo.hangs', () => new Promise(() => {})),
|
||||||
|
verb('demo.lies', async () => 'fine'),
|
||||||
|
verb('demo.forgets', async () => ({ resources: [] })),
|
||||||
|
])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
for (const id of ['demo.rejects', 'demo.throws', 'demo.hangs', 'demo.lies', 'demo.forgets']) {
|
||||||
|
const result = await dispatch.dispatchStep(step(id), { run: RUN })
|
||||||
|
assert.equal(result.outcome, 'retry', `${id} must not read as success`)
|
||||||
|
assert.ok(result.error, `${id} must say what went wrong`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module that means "never" says so, and only then is it terminal', async () => {
|
||||||
|
// The inverse of the rule above, and the reason `retry` is opted OUT of rather
|
||||||
|
// than into: an envelope that forgot to say anything gets the benefit of the
|
||||||
|
// doubt on the transient question, so only a module that states `retry: false`
|
||||||
|
// gets a step marked as never going to work.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([
|
||||||
|
{ id: 'demo.never', label: 'Never', risk: 'notify', reversible: 'none',
|
||||||
|
async perform() { return { ok: false, retry: false, error: 'there is no such gate' } } },
|
||||||
|
{ id: 'demo.later', label: 'Later', risk: 'notify', reversible: 'none',
|
||||||
|
async perform() { return { ok: false, error: 'the shard is restarting' } } },
|
||||||
|
])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
const never = await dispatch.dispatchStep(step('demo.never'), { run: RUN })
|
||||||
|
assert.equal(never.outcome, 'terminal')
|
||||||
|
assert.equal(never.error, 'there is no such gate')
|
||||||
|
|
||||||
|
const later = await dispatch.dispatchStep(step('demo.later'), { run: RUN })
|
||||||
|
assert.equal(later.outcome, 'retry')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('budgetMs is the contract’s deadline, and a wedged module does not hold the tick', async () => {
|
||||||
|
// Declared by the module, bounded by the registry, enforced by the dispatcher.
|
||||||
|
// Without it a `perform()` awaiting a socket that never answers holds the
|
||||||
|
// step's claim until the lease expires and the reclaim re-dispatches it, which
|
||||||
|
// is how one wedged sidecar becomes an infinite loop rather than a failed step.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.hangs', label: 'Hangs', risk: 'notify', reversible: 'none',
|
||||||
|
budgetMs: 120,
|
||||||
|
perform: () => new Promise(() => {}),
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
const started = Date.now()
|
||||||
|
const result = await dispatch.dispatchStep(step('demo.hangs'), { run: RUN })
|
||||||
|
assert.equal(result.outcome, 'retry')
|
||||||
|
assert.match(result.error, /exceeded its 120ms budget/)
|
||||||
|
assert.ok(Date.now() - started < 2000, 'the runner stopped waiting long before any lease would expire')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the two success shapes that mean "not finished" reach a module through the same door', async () => {
|
||||||
|
// §F, and the org lead's 2026-09-02 decision: both are ordinary envelope
|
||||||
|
// members rather than special cases keyed on an action id, so the runner never
|
||||||
|
// names a verb — and a module's own long-running action gets `await: 'human'`
|
||||||
|
// and `holdFor` for free, exactly as `core.cue` and `core.wait` do.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([
|
||||||
|
{ id: 'demo.parks', label: 'Parks', risk: 'notify', reversible: 'none',
|
||||||
|
async perform() { return { ok: true, await: 'human' } } },
|
||||||
|
{ id: 'demo.holds', label: 'Holds', risk: 'notify', reversible: 'none',
|
||||||
|
async perform() { return { ok: true, holdFor: 300 } } },
|
||||||
|
])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
const parked = await dispatch.dispatchStep(step('demo.parks'), { run: RUN })
|
||||||
|
assert.equal(parked.outcome, 'parked')
|
||||||
|
|
||||||
|
const held = await dispatch.dispatchStep(step('demo.holds'), { run: RUN })
|
||||||
|
assert.equal(held.outcome, 'done')
|
||||||
|
assert.equal(held.holdSeconds, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── verify: true is a parameter a module must honour ───────────────────────
|
||||||
|
|
||||||
|
test('verify rides through to the module unchanged, and a dry run changes nothing', async () => {
|
||||||
|
// §F: *"`verify: true` must change nothing and must answer honestly"*. Core
|
||||||
|
// cannot enforce the first half — only the module knows what its own writes
|
||||||
|
// are — so what IS testable is that the flag arrives, and that it arrives down
|
||||||
|
// the same dispatcher the real run uses. A dry run down a second code path is
|
||||||
|
// a dry run of the second path.
|
||||||
|
const written = []
|
||||||
|
global.__rgDemoWrites = written
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.writes', label: 'Writes', risk: 'change', reversible: 'none',
|
||||||
|
async perform({ verify, params }) {
|
||||||
|
if (verify) return { ok: true, wouldWrite: params.what }
|
||||||
|
global.__rgDemoWrites.push(params.what)
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
const dry = await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), {
|
||||||
|
run: RUN,
|
||||||
|
verify: true,
|
||||||
|
})
|
||||||
|
assert.equal(dry.outcome, 'done')
|
||||||
|
assert.deepEqual(written, [], 'a dry run wrote something')
|
||||||
|
|
||||||
|
await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), { run: RUN })
|
||||||
|
assert.deepEqual(written, ['a gate'])
|
||||||
|
delete global.__rgDemoWrites
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the whole envelope reaches the module, idempotency key included', async () => {
|
||||||
|
// The key is the module's half of a retry that is safe on the game side, and a
|
||||||
|
// module cannot pass it down its own wire if core does not hand it over. It is
|
||||||
|
// asserted here rather than in the runner's tests because THIS is the surface
|
||||||
|
// a module author reads.
|
||||||
|
global.__rgDemoEnvelope = null
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.echo', label: 'Echo', risk: 'notify', reversible: 'none',
|
||||||
|
async perform(envelope) { global.__rgDemoEnvelope = envelope; return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
await dispatch.dispatchStep(step('demo.echo', { a: 1 }), { run: { id: 7, scope: 'atlantic' } })
|
||||||
|
const envelope = global.__rgDemoEnvelope
|
||||||
|
assert.deepEqual(envelope, {
|
||||||
|
runId: 7,
|
||||||
|
stepId: 1,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
scope: 'atlantic',
|
||||||
|
params: { a: 1 },
|
||||||
|
actor: null,
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
delete global.__rgDemoEnvelope
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Budgets: a module cannot spend what it did not declare ─────────────────
|
||||||
|
|
||||||
|
test('a cost naming a dimension the module never declared is refused', async () => {
|
||||||
|
// §F, fail closed (org lead, 2026-09-03). The module here is not malicious and
|
||||||
|
// not exotic — it is one that declared two budgets and priced a third, which is
|
||||||
|
// what a rename looks like. The refusal has its own code because the fix is a
|
||||||
|
// module's declaration, not a deployment's cap.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||||||
|
cost: () => ({ 'demo.wraiths': 2 }),
|
||||||
|
async perform() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
// `settings: null` because this file has no database: passing it explicitly is
|
||||||
|
// what tells `mayInvoke` not to go and read the switchboard row, and the
|
||||||
|
// enablement layer answers before the cap layer either way.
|
||||||
|
const verdict = await authorize.mayInvoke({
|
||||||
|
action: registries.eventAction('demo.summon'),
|
||||||
|
params: {},
|
||||||
|
settings: null,
|
||||||
|
})
|
||||||
|
assert.equal(verdict.ok, false)
|
||||||
|
assert.equal(verdict.code, 'undeclared')
|
||||||
|
assert.match(verdict.reason, /demo\.wraiths/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a declared dimension is priced, named and unbounded until an operator says otherwise', async () => {
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' }])
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||||||
|
cost: (p) => ({ 'demo.wisps': p.count }),
|
||||||
|
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||||||
|
async perform() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
// What the switchboard renders: the id the action prices, dressed with what the
|
||||||
|
// module called it. Discovered by pricing the declared example, which is why
|
||||||
|
// §F makes `example` required on every param.
|
||||||
|
assert.deepEqual(authorize.budgetsOf(registries.eventAction('demo.summon')), [
|
||||||
|
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', registered: true },
|
||||||
|
])
|
||||||
|
|
||||||
|
// And with no settings row and no run, the answer is yes: a declared budget is
|
||||||
|
// a dimension that can be counted, not a bound that has been set.
|
||||||
|
const verdict = await authorize.mayInvoke({
|
||||||
|
action: registries.eventAction('demo.summon'),
|
||||||
|
params: { count: 4 },
|
||||||
|
settings: null,
|
||||||
|
})
|
||||||
|
assert.deepEqual(verdict, { ok: true, cost: { 'demo.wisps': 4 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Option sources ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a module answers its own option source, and core normalises what comes back', async () => {
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventOptionSources([{
|
||||||
|
id: 'demo.options.hues', label: 'Hues',
|
||||||
|
async resolve() {
|
||||||
|
return [
|
||||||
|
{ value: 1157, label: 'Blood', group: 'Reds' },
|
||||||
|
{ value: '2213', label: 'Ice' },
|
||||||
|
{ value: '', label: 'a blank nobody can pick' },
|
||||||
|
'not an option at all',
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
const answer = await registries.resolveOptionSource('demo.options.hues')
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
assert.equal(answer.owner, 'demo')
|
||||||
|
// Coerced to strings, because this array is rendered into a `<select>` and
|
||||||
|
// submitted back as text; and the two entries that could not become an option
|
||||||
|
// are dropped rather than becoming one that submits "undefined".
|
||||||
|
assert.deepEqual(answer.options, [
|
||||||
|
{ value: '1157', label: 'Blood', group: 'Reds' },
|
||||||
|
{ value: '2213', label: 'Ice' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every way an option source can fail degrades the field rather than breaking it', async () => {
|
||||||
|
// §F: a refusal degrades the field to free text with a visible warning. So none
|
||||||
|
// of these throws, and each says something an operator can act on — an
|
||||||
|
// authoring form a shard outage can make unusable is a worse failure than the
|
||||||
|
// typo the dropdown exists to prevent.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventOptionSources([
|
||||||
|
{ id: 'demo.options.throws', label: 'Throws', async resolve() { throw new Error('sidecar down') } },
|
||||||
|
{ id: 'demo.options.lies', label: 'Lies', async resolve() { return { nope: true } } },
|
||||||
|
{ id: 'demo.options.empty', label: 'Empty', async resolve() { return [] } },
|
||||||
|
])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
const threw = await registries.resolveOptionSource('demo.options.throws')
|
||||||
|
assert.equal(threw.ok, false)
|
||||||
|
assert.match(threw.reason, /could not be read/)
|
||||||
|
|
||||||
|
const lied = await registries.resolveOptionSource('demo.options.lies')
|
||||||
|
assert.equal(lied.ok, false)
|
||||||
|
assert.match(lied.reason, /no option list/)
|
||||||
|
|
||||||
|
// An EMPTY list is not a failure. A source that legitimately has nothing to
|
||||||
|
// offer today — no landmarks configured yet — must not be reported as broken,
|
||||||
|
// because the two have different fixes.
|
||||||
|
const empty = await registries.resolveOptionSource('demo.options.empty')
|
||||||
|
assert.equal(empty.ok, true)
|
||||||
|
assert.deepEqual(empty.options, [])
|
||||||
|
|
||||||
|
const missing = await registries.resolveOptionSource('demo.options.gone')
|
||||||
|
assert.equal(missing.ok, false)
|
||||||
|
assert.match(missing.reason, /no module registers/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Uninstall is dormancy, never an error ──────────────────────────────────
|
||||||
|
|
||||||
|
test('an action whose module is gone goes dormant, and a step naming it fails terminal with the module named', async () => {
|
||||||
|
// §F and §L, verbatim: *"a step naming one fails terminal with the module named
|
||||||
|
// and the run degrades — never a silent skip"*. The scenario is real and
|
||||||
|
// ordinary: a module was uninstalled between the publish that pinned the
|
||||||
|
// version and the run that dispatches it.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
|
||||||
|
async perform() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
assert.equal(registries.isEventAction('demo.summon'), true)
|
||||||
|
|
||||||
|
// The uninstall: a fresh scan of a directory the module is no longer in.
|
||||||
|
fs.rmSync(path.join(tmpRoot, 'demo'), { recursive: true, force: true })
|
||||||
|
loadModule('other', 'module.exports = () => {}')
|
||||||
|
assert.equal(registries.isEventAction('demo.summon'), false)
|
||||||
|
|
||||||
|
const result = await dispatch.dispatchStep(step('demo.summon'), { run: RUN })
|
||||||
|
assert.equal(result.outcome, 'terminal')
|
||||||
|
assert.equal(result.dormant, true)
|
||||||
|
assert.match(result.error, /no module registers "demo\.summon"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Phase 8: the ledger's two callables, from a module ─────────────────────
|
||||||
|
//
|
||||||
|
// `revert` was already required at registration for `reversible: 'ledger'` —
|
||||||
|
// Phase 1 put that check in. What Phase 8 added is a caller for it, and
|
||||||
|
// `reconcile` beside it. Both are proved here through the REAL loader for the
|
||||||
|
// same reason the four registrations are: a `revert` a test called directly is a
|
||||||
|
// `revert` core might still have no way to reach.
|
||||||
|
|
||||||
|
test('a module\'s revert is reached by the cleanup sweep, resources and key in hand', async () => {
|
||||||
|
const record = loadModule('demo', `
|
||||||
|
let seen = null
|
||||||
|
module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'demo.spawn',
|
||||||
|
label: 'Spawn',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
params: [],
|
||||||
|
async perform() { return { ok: true, resources: [{ kind: 'creature', ref: '0xA' }] } },
|
||||||
|
async revert(arg) { seen = arg; return { ok: true } },
|
||||||
|
async reconcile() { return { ok: true, inForce: [] } },
|
||||||
|
}])
|
||||||
|
api.registerEventOptionSources([
|
||||||
|
{ id: 'demo.options.seen', label: 'seen', async resolve() { return [{ value: JSON.stringify(seen), label: 'seen' }] } },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
assertRegistered(record)
|
||||||
|
|
||||||
|
const action = registries.eventAction('demo.spawn')
|
||||||
|
// Both callables survived the registration copy — which is not a given: that
|
||||||
|
// copy is explicit rather than a spread, precisely so nothing rides along, and
|
||||||
|
// a member added to the contract without being added to it is a member that
|
||||||
|
// silently does not exist.
|
||||||
|
assert.equal(typeof action.revert, 'function')
|
||||||
|
assert.equal(typeof action.reconcile, 'function')
|
||||||
|
assert.equal(action.owner, 'demo')
|
||||||
|
|
||||||
|
const answer = await action.revert({
|
||||||
|
runId: 3,
|
||||||
|
resources: [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }],
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
})
|
||||||
|
assert.deepEqual(answer, { ok: true })
|
||||||
|
|
||||||
|
// Read back through the module's own option source rather than out of a
|
||||||
|
// closure this file holds: the point is that what core PASSED is what the
|
||||||
|
// module SAW, across the seam.
|
||||||
|
const seen = JSON.parse((await registries.resolveOptionSource('demo.options.seen')).options[0].value)
|
||||||
|
assert.equal(seen.runId, 3)
|
||||||
|
assert.equal(seen.idempotencyKey, 'k-1')
|
||||||
|
assert.deepEqual(seen.resources, [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reconcile is optional, and a module without one still registers', () => {
|
||||||
|
// The asymmetry with `revert`, from the loader's side. A module that cannot say
|
||||||
|
// what the game still has is not broken — core keeps believing its own ledger,
|
||||||
|
// which is the behaviour before this phase — whereas one that creates something
|
||||||
|
// and cannot undo it has made a promise core has no way to keep.
|
||||||
|
const withNone = loadModule('quiet', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'quiet.spawn',
|
||||||
|
label: 'Spawn',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
params: [],
|
||||||
|
async perform() { return { ok: true } },
|
||||||
|
async revert() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`)
|
||||||
|
assertRegistered(withNone)
|
||||||
|
assert.equal(registries.eventAction('quiet.spawn').reconcile, null)
|
||||||
|
|
||||||
|
const withoutRevert = loadModule('broken', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'broken.spawn',
|
||||||
|
label: 'Spawn',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
params: [],
|
||||||
|
async perform() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`)
|
||||||
|
assert.equal(withoutRevert.state, 'startup_failed')
|
||||||
|
assert.match(withoutRevert.reason, /reversible: 'ledger' but has no revert\(\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module cannot claim core\'s reserved resource kind', async () => {
|
||||||
|
// `@step` is the placeholder's kind, and the placeholder is the row whose
|
||||||
|
// survival is the safety property: a module able to write one could make its
|
||||||
|
// own step look already accounted for. Refused at recording, and the STEP still
|
||||||
|
// succeeds — because it did.
|
||||||
|
const record = loadModule('sneaky', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventActions([{
|
||||||
|
id: 'sneaky.spawn',
|
||||||
|
label: 'Spawn',
|
||||||
|
risk: 'change',
|
||||||
|
reversible: 'ledger',
|
||||||
|
params: [],
|
||||||
|
async perform() { return { ok: true, resources: [{ kind: '@step', ref: 'anything' }] } },
|
||||||
|
async revert() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`)
|
||||||
|
assertRegistered(record)
|
||||||
|
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const ledger = require('../src/events/ledger')
|
||||||
|
const result = await dispatch.dispatchStep(step('sneaky.spawn'), { run: RUN })
|
||||||
|
assert.equal(result.outcome, 'done')
|
||||||
|
const parsed = ledger.normalise(result.resources[0], 'sneaky.spawn')
|
||||||
|
assert.equal(parsed.ok, false)
|
||||||
|
assert.match(parsed.reason, /reserved kind/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('core registers the lease VERB and a module registers the lease', async () => {
|
||||||
|
// The seam working the way round it is meant to (Phase 8). A module ships the
|
||||||
|
// three callables; the verb an author puts in a step is `core.lease`, so the
|
||||||
|
// duration bound and the two-events-one-target conflict check live in one place
|
||||||
|
// rather than being re-implemented once per module and advisory everywhere.
|
||||||
|
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: 'demo.rate.gain',
|
||||||
|
label: 'Gain rate',
|
||||||
|
type: 'float',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
async read() { return { ok: true, value: 1 } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`)
|
||||||
|
assertRegistered(record)
|
||||||
|
registries.registerCore()
|
||||||
|
|
||||||
|
// The module registers no ACTION at all, and its lease is still reachable.
|
||||||
|
assert.equal(registries.eventAction('demo.lease'), null)
|
||||||
|
assert.equal(registries.eventAction('core.lease').reversible, 'override')
|
||||||
|
|
||||||
|
// And the dropdown behind `core.lease`'s first param is answered by what the
|
||||||
|
// module declared — resolved per request, so a module that booted later is
|
||||||
|
// still in the list.
|
||||||
|
const options = await registries.resolveOptionSource('core.options.leases')
|
||||||
|
assert.equal(options.ok, true)
|
||||||
|
assert.deepEqual(options.options, [{ value: 'demo.rate.gain', label: 'Gain rate', group: 'demo' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inForce() is optional, and a fourth question rather than a fourth spelling of read()', () => {
|
||||||
|
// Phase 11b. `read` is "what is it now", `apply` is "hold it here", `restore` is
|
||||||
|
// "put it back and tell me if somebody moved it" -- and none of them answers
|
||||||
|
// "does the game side still have any record of this hold?", which is what a
|
||||||
|
// reconcile after an outage needs. A config lease is reverted by a shard restart
|
||||||
|
// by design, so the answer changes without the value ever being written by us.
|
||||||
|
//
|
||||||
|
// Optional, because the fallback is core's posture everywhere: a lease that
|
||||||
|
// cannot say leaves its ledger row alone.
|
||||||
|
//
|
||||||
|
// Three module IDs rather than three reloads of one: `loadModule` rewrites
|
||||||
|
// `index.js` in place and clears the LOADER from the require cache, but not the
|
||||||
|
// module file it goes on to require. A second load of the same id silently
|
||||||
|
// re-registers the first source.
|
||||||
|
const lease = (id, extra) => `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: '${id}.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
async read() { return { ok: true, value: 1 } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
${extra}
|
||||||
|
}])
|
||||||
|
}`
|
||||||
|
|
||||||
|
const without = loadModule('leasea', lease('leasea', ''))
|
||||||
|
assertRegistered(without)
|
||||||
|
assert.equal(registries.eventLease('leasea.rate.gain').inForce, null)
|
||||||
|
|
||||||
|
const withIt = loadModule('leaseb', lease('leaseb', 'async inForce() { return { ok: true, held: true } },'))
|
||||||
|
assertRegistered(withIt)
|
||||||
|
assert.equal(typeof registries.eventLease('leaseb.rate.gain').inForce, 'function')
|
||||||
|
|
||||||
|
// And a declaration that is present but not callable fails the module rather
|
||||||
|
// than being ignored -- the same posture the other three take. A module that
|
||||||
|
// meant to answer and cannot is a module whose leases would silently never be
|
||||||
|
// reconciled.
|
||||||
|
const broken = loadModule('leasec', lease('leasec', "inForce: 'yes',"))
|
||||||
|
assert.equal(broken.state, 'startup_failed')
|
||||||
|
assert.match(broken.reason, /inForce must be a function/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('core refuses a lease held longer than the module allows', async () => {
|
||||||
|
// The bound is the MODULE's number and the enforcement is CORE's, which is the
|
||||||
|
// §F split stated as one assertion. `retry: false` because a duration that is
|
||||||
|
// too long will still be too long in sixty seconds: it is an authoring error,
|
||||||
|
// not an outage.
|
||||||
|
const record = loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: 'demo.rate.gain',
|
||||||
|
label: 'Gain rate',
|
||||||
|
type: 'float',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
async read() { return { ok: true, value: 1 } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`)
|
||||||
|
assertRegistered(record)
|
||||||
|
registries.registerCore()
|
||||||
|
|
||||||
|
const tooLong = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 120 }),
|
||||||
|
{ run: RUN },
|
||||||
|
)
|
||||||
|
assert.equal(tooLong.outcome, 'terminal')
|
||||||
|
assert.match(tooLong.error, /at most 60 minutes, not 120/)
|
||||||
|
|
||||||
|
// The same for a value outside the declared range. Unlike a cap, a bad lease
|
||||||
|
// value is in force the moment it is applied, which is why min/max are required
|
||||||
|
// on the numeric types rather than advisory.
|
||||||
|
const tooBig = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.rate.gain', value: '9', minutes: 10 }),
|
||||||
|
{ run: RUN },
|
||||||
|
)
|
||||||
|
assert.equal(tooBig.outcome, 'terminal')
|
||||||
|
assert.match(tooBig.error, /accepts 0\.5 to 5/)
|
||||||
|
|
||||||
|
// And a lease nobody registers, which is the dormancy rule one registry along.
|
||||||
|
const missing = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.nope', value: '3', minutes: 10 }),
|
||||||
|
{ run: RUN },
|
||||||
|
)
|
||||||
|
assert.equal(missing.outcome, 'terminal')
|
||||||
|
assert.match(missing.error, /no module registers the lease "demo\.nope"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run of core.lease checks everything and takes nothing', async () => {
|
||||||
|
// `verify: true` must change nothing and must answer honestly (§F). A verify
|
||||||
|
// that reserved the target would be a dry run that changed something — and it
|
||||||
|
// would then refuse the real run that followed it, which is the worst of both.
|
||||||
|
let applied = 0
|
||||||
|
const record = loadModule('demo', `
|
||||||
|
let applied = 0
|
||||||
|
module.exports = (ctx, api) => {
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: 'demo.rate.gain',
|
||||||
|
label: 'Gain rate',
|
||||||
|
type: 'float',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
async read() { return { ok: true, value: 1 } },
|
||||||
|
async apply() { applied += 1; return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
api.registerEventOptionSources([
|
||||||
|
{ id: 'demo.options.applied', label: 'applied', async resolve() { return [{ value: String(applied), label: 'n' }] } },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
assertRegistered(record)
|
||||||
|
registries.registerCore()
|
||||||
|
void applied
|
||||||
|
|
||||||
|
const ok = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10 }),
|
||||||
|
{ run: RUN, verify: true },
|
||||||
|
)
|
||||||
|
assert.equal(ok.outcome, 'done')
|
||||||
|
assert.equal((await registries.resolveOptionSource('demo.options.applied')).options[0].value, '0')
|
||||||
|
|
||||||
|
// A dry run that is still a real check: the bad duration is caught with
|
||||||
|
// `verify: true` as well, which is the whole value of the switchboard's
|
||||||
|
// "find out before you schedule it".
|
||||||
|
const bad = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 999 }),
|
||||||
|
{ run: RUN, verify: true },
|
||||||
|
)
|
||||||
|
assert.equal(bad.outcome, 'terminal')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Targeted leases and searchable sources (Phase 12b) ──────────────────────
|
||||||
|
|
||||||
|
test('a targeted lease is a family of values, and the target rides in the ref', async () => {
|
||||||
|
// The shape §F did not have before this phase. Every lease until now named ONE
|
||||||
|
// value, so the lease id WAS the target and the callables needed none. An
|
||||||
|
// object property is one lease over thousands of objects, and the reservation
|
||||||
|
// has to distinguish them or a run turning up one spawner locks out every other
|
||||||
|
// run and every other spawner.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
const seen = []
|
||||||
|
api.registerEventOptionSources([{
|
||||||
|
id: 'demo.options.seen', label: 'Seen',
|
||||||
|
async resolve() { return seen.map((s, i) => ({ value: String(i), label: s })) },
|
||||||
|
}])
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: 'demo.spawner.count',
|
||||||
|
label: 'Spawner count',
|
||||||
|
type: 'int', min: 0, max: 50,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
target: { label: 'Which spawner', source: 'demo.options.seen', example: 'abc' },
|
||||||
|
async read(a) { seen.push('read:' + JSON.stringify(a)); return { ok: true, value: 3 } },
|
||||||
|
async apply(v, until, a) { seen.push('apply:' + JSON.stringify(a)); return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
registries.registerCore()
|
||||||
|
|
||||||
|
const lease = registries.eventLease('demo.spawner.count')
|
||||||
|
assert.deepEqual(lease.target, {
|
||||||
|
label: 'Which spawner',
|
||||||
|
source: 'demo.options.seen',
|
||||||
|
example: 'abc',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ref composes, and the parser takes it apart again — the property every
|
||||||
|
// reader of a ledgered lease depends on.
|
||||||
|
const ref = registries.leaseRef('demo.spawner.count', '0x40001234')
|
||||||
|
assert.equal(ref, 'demo.spawner.count#0x40001234')
|
||||||
|
assert.deepEqual(registries.parseLeaseRef(ref), {
|
||||||
|
id: 'demo.spawner.count',
|
||||||
|
target: '0x40001234',
|
||||||
|
})
|
||||||
|
assert.equal(registries.eventLeaseForRef(ref).lease.id, 'demo.spawner.count')
|
||||||
|
assert.equal(registries.eventLeaseForRef(ref).target, '0x40001234')
|
||||||
|
|
||||||
|
// An untargeted ref still parses to a bare id, so nothing about the leases that
|
||||||
|
// existed before this phase changes.
|
||||||
|
assert.deepEqual(registries.parseLeaseRef('demo.rate'), { id: 'demo.rate', target: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a targeted lease refuses an empty target, and an untargeted one refuses a target', async () => {
|
||||||
|
// Both are authoring mistakes rather than outages, so both are terminal: the
|
||||||
|
// second attempt has exactly the same params, and a retry loop against a form
|
||||||
|
// error is a run that pauses for nothing.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventLeases([
|
||||||
|
{
|
||||||
|
id: 'demo.spawner.count', label: 'Spawner count', type: 'int', min: 0, max: 50,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
target: { label: 'Which spawner' },
|
||||||
|
async read() { return { ok: true, value: 3 } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5,
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
async read() { return { ok: true, value: 1 } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}`))
|
||||||
|
registries.registerCore()
|
||||||
|
|
||||||
|
const noTarget = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.spawner.count', value: '9', minutes: 10 }),
|
||||||
|
{ run: RUN, verify: true },
|
||||||
|
)
|
||||||
|
assert.equal(noTarget.outcome, 'terminal')
|
||||||
|
assert.match(noTarget.error, /needs a which spawner/i)
|
||||||
|
|
||||||
|
const spuriousTarget = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10, target: '0x1' }),
|
||||||
|
{ run: RUN, verify: true },
|
||||||
|
)
|
||||||
|
assert.equal(spuriousTarget.outcome, 'terminal')
|
||||||
|
assert.match(spuriousTarget.error, /takes no target/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a string lease with a declared value set is bounded at authoring time', async () => {
|
||||||
|
// `min`/`max` bound the numeric types and nothing bounded `string`, so the only
|
||||||
|
// check on a string lease's value was the game side's — a refusal that arrives
|
||||||
|
// unattended, mid-run. The set closes that at the form.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventLeases([{
|
||||||
|
id: 'demo.season.status', label: 'Seasonal status', type: 'string',
|
||||||
|
values: ['Inactive', 'Active', 'Seasonal'],
|
||||||
|
maxDurationMs: 3600000,
|
||||||
|
target: { label: 'Which event' },
|
||||||
|
async read() { return { ok: true, value: 'Inactive' } },
|
||||||
|
async apply() { return { ok: true } },
|
||||||
|
async restore() { return { ok: true } },
|
||||||
|
}])
|
||||||
|
}`))
|
||||||
|
registries.registerCore()
|
||||||
|
|
||||||
|
const bad = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.season.status', value: 'On', minutes: 10, target: 'Fellowship' }),
|
||||||
|
{ run: RUN, verify: true },
|
||||||
|
)
|
||||||
|
assert.equal(bad.outcome, 'terminal')
|
||||||
|
assert.match(bad.error, /Inactive, Active, Seasonal/)
|
||||||
|
|
||||||
|
const good = await dispatch.dispatchStep(
|
||||||
|
step('core.lease', { lease: 'demo.season.status', value: 'Active', minutes: 10, target: 'Fellowship' }),
|
||||||
|
{ run: RUN, verify: true },
|
||||||
|
)
|
||||||
|
assert.equal(good.outcome, 'done')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a values list belongs to a string lease and to no other type', () => {
|
||||||
|
// Closed like `type` itself, and for the same reason: a set on an int lease
|
||||||
|
// would be a second bound beside `min`/`max` with no rule about which wins.
|
||||||
|
const api = registries.stage('demo')
|
||||||
|
assert.throws(
|
||||||
|
() => api.registerEventLeases([{
|
||||||
|
id: 'demo.n', label: 'N', type: 'int', min: 0, max: 5, values: ['1', '2'],
|
||||||
|
maxDurationMs: 1000,
|
||||||
|
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
|
||||||
|
}]),
|
||||||
|
/values is for string leases/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => api.registerEventLeases([{
|
||||||
|
id: 'demo.s', label: 'S', type: 'string', values: [],
|
||||||
|
maxDurationMs: 1000,
|
||||||
|
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
|
||||||
|
}]),
|
||||||
|
/non-empty array/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => api.registerEventLeases([{
|
||||||
|
id: 'demo.t', label: 'T', type: 'bool', target: { source: 'nope' },
|
||||||
|
maxDurationMs: 1000,
|
||||||
|
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
|
||||||
|
}]),
|
||||||
|
/target has no label/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a source is passed the search term whether or not it reads one', async () => {
|
||||||
|
// Additive on purpose: `resolve({ q })` reaches every source, and one that
|
||||||
|
// ignores the argument answers exactly as it did before this existed. Only the
|
||||||
|
// sources too large for a dropdown have to care, and `searchable` is what says
|
||||||
|
// which those are — inferring it from a truncated answer would read correctly
|
||||||
|
// right up until a small shard's list happens to fit.
|
||||||
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||||
|
api.registerEventOptionSources([
|
||||||
|
{
|
||||||
|
id: 'demo.options.big', label: 'Big', searchable: true,
|
||||||
|
async resolve({ q } = {}) {
|
||||||
|
const all = ['alpha', 'beta', 'gamma']
|
||||||
|
const hits = q ? all.filter((n) => n.includes(q)) : all
|
||||||
|
return hits.map((n) => ({ value: n, label: n }))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'demo.options.small', label: 'Small', async resolve() { return [{ value: 'only' }] } },
|
||||||
|
])
|
||||||
|
}`))
|
||||||
|
|
||||||
|
const all = await registries.resolveOptionSource('demo.options.big')
|
||||||
|
assert.deepEqual(all.options.map((o) => o.value), ['alpha', 'beta', 'gamma'])
|
||||||
|
assert.equal(all.searchable, true)
|
||||||
|
|
||||||
|
const narrowed = await registries.resolveOptionSource('demo.options.big', { q: 'et' })
|
||||||
|
assert.deepEqual(narrowed.options.map((o) => o.value), ['beta'])
|
||||||
|
assert.equal(narrowed.q, 'et')
|
||||||
|
|
||||||
|
// The source that ignores it is unchanged, and says it is not searchable so the
|
||||||
|
// form renders a select rather than a box that appears to filter and does not.
|
||||||
|
const small = await registries.resolveOptionSource('demo.options.small', { q: 'anything' })
|
||||||
|
assert.deepEqual(small.options, [{ value: 'only', label: 'only' }])
|
||||||
|
assert.equal(small.searchable, false)
|
||||||
|
|
||||||
|
// Bounded rather than refused: a term this long is a paste, and truncating it
|
||||||
|
// still answers something where refusing would blank the form mid-keystroke.
|
||||||
|
const long = await registries.resolveOptionSource('demo.options.big', { q: 'x'.repeat(400) })
|
||||||
|
assert.equal(long.ok, true)
|
||||||
|
assert.equal(long.q.length, 120)
|
||||||
|
})
|
||||||
193
server/test/eventParticipants.test.js
Normal file
193
server/test/eventParticipants.test.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// ── Who took part (EVENTS_PLAN.md Phase 10) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// The write half of `event_run_participants`, and it is `events/ledger.js`'s
|
||||||
|
// twin in every respect that matters — same envelope, same two success shapes,
|
||||||
|
// same fail-open-on-a-bad-entry posture. The properties worth a test are the
|
||||||
|
// ones where getting it wrong is silent:
|
||||||
|
//
|
||||||
|
// • a module's bad entry is DROPPED, never a retry — a retried step is a
|
||||||
|
// re-dispatched world write, which is a far worse outcome than one missing
|
||||||
|
// name on a leaderboard
|
||||||
|
// • a `userId` is checked rather than coerced, because the column is a foreign
|
||||||
|
// key into `users` and a character serial that happened to be a real user id
|
||||||
|
// would attribute somebody's attendance to a stranger
|
||||||
|
// • one step may not report the same member twice, and the duplicate is NAMED
|
||||||
|
// rather than silently letting the last one win
|
||||||
|
// • the classifier carries `participants` on both success shapes, including
|
||||||
|
// `await: 'human'` — a cue's confirm finishes the step without a second
|
||||||
|
// dispatch, so that is the only moment its participants can be recorded
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const participants = require('../src/events/participants')
|
||||||
|
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||||
|
const { classify } = require('../src/events/dispatch')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const ACTION = { id: 'uo.participants.collect' }
|
||||||
|
const RUN = { id: 42 }
|
||||||
|
const STEP = { id: 7, phase: 'main' }
|
||||||
|
|
||||||
|
let written
|
||||||
|
const original = participantsDb.record
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
written = []
|
||||||
|
participantsDb.record = async (row) => {
|
||||||
|
written.push(row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
participantsDb.record = original
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── normalise ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a member key is required and is the only required field', () => {
|
||||||
|
const ok = participants.normalise({ memberKey: 'darrow' }, ACTION.id)
|
||||||
|
assert.equal(ok.ok, true)
|
||||||
|
assert.equal(ok.row.memberKey, 'darrow')
|
||||||
|
assert.equal(ok.row.userId, null)
|
||||||
|
assert.equal(ok.row.score, 0)
|
||||||
|
assert.equal(ok.row.meta, null)
|
||||||
|
assert.equal(ok.row.joinedAt, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every bad shape is refused, and each names what was wrong', () => {
|
||||||
|
const bad = [
|
||||||
|
[null, /not an object/],
|
||||||
|
['darrow', /not an object/],
|
||||||
|
[[], /not an object/],
|
||||||
|
[{}, /bad memberKey/],
|
||||||
|
[{ memberKey: '' }, /bad memberKey/],
|
||||||
|
[{ memberKey: 'x'.repeat(participants.MAX_MEMBER_KEY + 1) }, /bad memberKey/],
|
||||||
|
[{ memberKey: 'd', userId: 0 }, /bad userId/],
|
||||||
|
[{ memberKey: 'd', userId: -3 }, /bad userId/],
|
||||||
|
[{ memberKey: 'd', userId: 1.5 }, /bad userId/],
|
||||||
|
[{ memberKey: 'd', userId: '4' }, /bad userId/],
|
||||||
|
[{ memberKey: 'd', score: 'lots' }, /bad score/],
|
||||||
|
[{ memberKey: 'd', score: Number.NaN }, /bad score/],
|
||||||
|
[{ memberKey: 'd', score: Infinity }, /bad score/],
|
||||||
|
[{ memberKey: 'd', joinedAt: 'yesterday' }, /bad joinedAt/],
|
||||||
|
]
|
||||||
|
for (const [entry, pattern] of bad) {
|
||||||
|
const parsed = participants.normalise(entry, ACTION.id)
|
||||||
|
assert.equal(parsed.ok, false, `${JSON.stringify(entry)} should be refused`)
|
||||||
|
assert.match(parsed.reason, pattern)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a userId that is a real integer rides through; a serial-shaped string does not', () => {
|
||||||
|
assert.equal(participants.normalise({ memberKey: 'd', userId: 12 }, ACTION.id).row.userId, 12)
|
||||||
|
assert.equal(participants.normalise({ memberKey: 'd', userId: '0x4001' }, ACTION.id).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a negative score is legal — a game may score downward', () => {
|
||||||
|
assert.equal(participants.normalise({ memberKey: 'd', score: -40 }, ACTION.id).row.score, -40)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('meta that is not an object is dropped rather than refusing the whole participant', () => {
|
||||||
|
// Decoration on a row whose identity is already valid. Losing an event's
|
||||||
|
// attendance over a stray string would be the wrong trade.
|
||||||
|
const parsed = participants.normalise({ memberKey: 'd', meta: 'warrior' }, ACTION.id)
|
||||||
|
assert.equal(parsed.ok, true)
|
||||||
|
assert.equal(parsed.row.meta, null)
|
||||||
|
assert.deepEqual(participants.normalise({ memberKey: 'd', meta: { c: 'mage' } }, ACTION.id).row.meta, { c: 'mage' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── recordAnswer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('nothing reported is not an error and writes nothing', async () => {
|
||||||
|
assert.deepEqual(await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: undefined }), {
|
||||||
|
recorded: 0,
|
||||||
|
rejected: [],
|
||||||
|
})
|
||||||
|
assert.equal(written.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a bad entry never fails the step, and the good ones beside it still land', async () => {
|
||||||
|
const out = await participants.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: STEP,
|
||||||
|
action: ACTION,
|
||||||
|
participants: [{ memberKey: 'darrow', score: 12 }, { userId: 4 }, { memberKey: 'marisol' }],
|
||||||
|
})
|
||||||
|
assert.equal(out.recorded, 2)
|
||||||
|
assert.equal(out.rejected.length, 1)
|
||||||
|
assert.match(out.rejected[0], /bad memberKey/)
|
||||||
|
assert.deepEqual(written.map((w) => w.memberKey), ['darrow', 'marisol'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one step reporting the same member twice writes one row and names the duplicate', async () => {
|
||||||
|
const out = await participants.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: STEP,
|
||||||
|
action: ACTION,
|
||||||
|
participants: [{ memberKey: 'darrow', score: 12 }, { memberKey: 'darrow', score: 99 }],
|
||||||
|
})
|
||||||
|
assert.equal(out.recorded, 1)
|
||||||
|
assert.match(out.rejected[0], /twice in one step/)
|
||||||
|
assert.equal(written.length, 1)
|
||||||
|
assert.equal(written[0].score, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('more participants than one step may report is refused WHOLE, not truncated', async () => {
|
||||||
|
// Half a leaderboard silently cut is worse than none: the table would look
|
||||||
|
// complete and be wrong, and nothing downstream could tell.
|
||||||
|
const many = Array.from({ length: participants.MAX_PER_STEP + 1 }, (_, i) => ({ memberKey: `m${i}` }))
|
||||||
|
const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: many })
|
||||||
|
assert.equal(out.recorded, 0)
|
||||||
|
assert.equal(written.length, 0)
|
||||||
|
assert.match(out.rejected[0], /more than the/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a write that throws is recorded as a rejection rather than becoming the step\'s control flow', async () => {
|
||||||
|
participantsDb.record = async () => {
|
||||||
|
throw new Error('deadlock found when trying to get lock')
|
||||||
|
}
|
||||||
|
const out = await participants.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: STEP,
|
||||||
|
action: ACTION,
|
||||||
|
participants: [{ memberKey: 'darrow' }],
|
||||||
|
})
|
||||||
|
assert.equal(out.recorded, 0)
|
||||||
|
assert.match(out.rejected[0], /deadlock/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the run id is bound by the caller and never taken from the entry', async () => {
|
||||||
|
await participants.recordAnswer({
|
||||||
|
run: RUN,
|
||||||
|
step: STEP,
|
||||||
|
action: ACTION,
|
||||||
|
// A module cannot record somebody into another run by saying so.
|
||||||
|
participants: [{ memberKey: 'darrow', runId: 99999 }],
|
||||||
|
})
|
||||||
|
assert.equal(written[0].runId, RUN.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the classifier carries them ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('participants ride back on BOTH success shapes, and default to an empty list', () => {
|
||||||
|
assert.deepEqual(classify({ ok: true, participants: [{ memberKey: 'd' }] }, 'a').participants, [{ memberKey: 'd' }])
|
||||||
|
assert.deepEqual(
|
||||||
|
classify({ ok: true, await: 'human', participants: [{ memberKey: 'd' }] }, 'a').participants,
|
||||||
|
[{ memberKey: 'd' }],
|
||||||
|
)
|
||||||
|
assert.deepEqual(classify({ ok: true }, 'a').participants, [])
|
||||||
|
assert.deepEqual(classify({ ok: true, await: 'human' }, 'a').participants, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a FAILED envelope carries no participants at all', () => {
|
||||||
|
// A step that did not succeed did not observe anybody, and an action that
|
||||||
|
// reported attendance alongside a refusal is reporting something it cannot
|
||||||
|
// know. There is no `participants` on a failure classification to read.
|
||||||
|
assert.equal(classify({ ok: false, participants: [{ memberKey: 'd' }] }, 'a').participants, undefined)
|
||||||
|
})
|
||||||
309
server/test/eventPrice.test.js
Normal file
309
server/test/eventPrice.test.js
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// ── The live cap meter (EVENTS_PLAN.md Phase 13) ───────────────────────────
|
||||||
|
//
|
||||||
|
// `events/price.js` answers what a plan would spend without dispatching a thing.
|
||||||
|
// Three of the tests below protect a decision rather than a mechanism, and they
|
||||||
|
// are the reason this file exists apart from `eventVerify.test.js`:
|
||||||
|
//
|
||||||
|
// • **A step core cannot price is reported, never counted as free.** All three
|
||||||
|
// ways that happens — a dormant action, a `cost()` that broke its own
|
||||||
|
// contract, and a dimension nobody declared — make the totals an UNDER-count,
|
||||||
|
// and a meter an author trusts that reads lower than what will happen is
|
||||||
|
// worse than no meter at all.
|
||||||
|
// • **An undeclared dimension is still counted.** It is unenforceable, not
|
||||||
|
// unknown: the action really will try to spend it, and the step is refused at
|
||||||
|
// dispatch for that reason. Reporting it as costing nothing would hide both
|
||||||
|
// facts at once.
|
||||||
|
// • **The route dispatches nothing.** An action whose `perform()` would throw
|
||||||
|
// prices perfectly well here, which is what makes the meter safe on a
|
||||||
|
// debounce — `verify` puts a module and a sidecar behind every call and this
|
||||||
|
// deliberately does not.
|
||||||
|
//
|
||||||
|
// The registry is the real one, staged and applied the way a module does it, for
|
||||||
|
// `eventAuthorize.test.js`'s reason: an action that would not register is not one
|
||||||
|
// this file has to survive.
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const registries = require('../src/modules/registries')
|
||||||
|
const price = require('../src/events/price')
|
||||||
|
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const originalSettings = { ...settingsDb }
|
||||||
|
|
||||||
|
let settings
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registries._reset()
|
||||||
|
settings = new Map()
|
||||||
|
settingsDb.byIds = async (ids) =>
|
||||||
|
new Map([...new Set(ids || [])].filter((i) => settings.has(i)).map((i) => [i, settings.get(i)]))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(settingsDb, originalSettings)
|
||||||
|
registries._reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
const action = (id, over = {}) => ({
|
||||||
|
id,
|
||||||
|
label: over.label || id,
|
||||||
|
risk: over.risk || 'notify',
|
||||||
|
reversible: over.reversible || 'none',
|
||||||
|
params: over.params || [],
|
||||||
|
...(over.cost ? { cost: over.cost } : {}),
|
||||||
|
async perform() {
|
||||||
|
return over.perform ? over.perform() : { ok: true }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const register = (entries, { owner = 'test', budgets = [] } = {}) => {
|
||||||
|
const api = registries.stage(owner)
|
||||||
|
api.registerEventActions(entries)
|
||||||
|
if (budgets.length) api.registerEventBudgets(budgets.map((id) => ({ id, label: id, unit: 'count' })))
|
||||||
|
registries.apply(api.staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCaps = (id, caps) => settings.set(id, { action_id: id, enabled: 1, caps })
|
||||||
|
|
||||||
|
/** `{ phases: [...] }` out of a compact `[[step, step], [step]]`. */
|
||||||
|
const spec = (phases) => ({
|
||||||
|
phases: phases.map((steps, i) => ({
|
||||||
|
key: `phase${i + 1}`,
|
||||||
|
steps: steps.map(([actionId, params = {}]) => ({ actionId, params })),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dimension = (report, id) => report.cost.find((c) => c.dimension === id)
|
||||||
|
|
||||||
|
// ── The whole-plan total, which is the number the meter exists to show ──────
|
||||||
|
|
||||||
|
test('adds a dimension up across every phase and compares it to the tightest cap', async () => {
|
||||||
|
register(
|
||||||
|
[
|
||||||
|
action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
|
||||||
|
action('test.boss', { cost: () => ({ 'test.creatures': 1 }) }),
|
||||||
|
],
|
||||||
|
{ budgets: ['test.creatures'] },
|
||||||
|
)
|
||||||
|
setCaps('test.spawn', { 'test.creatures': 30 })
|
||||||
|
// The tightest cap wins: two actions spending one dimension have to agree on
|
||||||
|
// one number, and a safety limit settles on the smaller.
|
||||||
|
setCaps('test.boss', { 'test.creatures': 24 })
|
||||||
|
|
||||||
|
const report = await price.priceSpec(
|
||||||
|
spec([
|
||||||
|
[['test.spawn', { count: 15 }]],
|
||||||
|
[['test.spawn', { count: 15 }], ['test.boss', {}]],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(report.ok, true)
|
||||||
|
assert.equal(report.steps, 3)
|
||||||
|
assert.equal(report.priced, 3)
|
||||||
|
assert.deepEqual(dimension(report, 'test.creatures'), {
|
||||||
|
dimension: 'test.creatures',
|
||||||
|
total: 31,
|
||||||
|
cap: 24,
|
||||||
|
from: 'test.boss',
|
||||||
|
over: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a plan inside its cap is not over', async () => {
|
||||||
|
register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) })], {
|
||||||
|
budgets: ['test.creatures'],
|
||||||
|
})
|
||||||
|
setCaps('test.spawn', { 'test.creatures': 30 })
|
||||||
|
|
||||||
|
const report = await price.priceSpec(spec([[['test.spawn', { count: 12 }]]]))
|
||||||
|
assert.equal(dimension(report, 'test.creatures').over, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dimension nobody caps comes back uncapped rather than missing', async () => {
|
||||||
|
register([action('test.say', { cost: () => ({ 'test.broadcasts': 1 }) })], { budgets: ['test.broadcasts'] })
|
||||||
|
|
||||||
|
const report = await price.priceSpec(spec([[['test.say', {}]]]))
|
||||||
|
assert.deepEqual(dimension(report, 'test.broadcasts'), {
|
||||||
|
dimension: 'test.broadcasts',
|
||||||
|
total: 1,
|
||||||
|
cap: null,
|
||||||
|
from: null,
|
||||||
|
over: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The per-phase draw the timeline renders ────────────────────────────────
|
||||||
|
|
||||||
|
test('reports the draw per phase, in timeline order, including a phase that spends nothing', async () => {
|
||||||
|
register(
|
||||||
|
[
|
||||||
|
action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
|
||||||
|
action('test.wait'),
|
||||||
|
],
|
||||||
|
{ budgets: ['test.creatures'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = await price.priceSpec(
|
||||||
|
spec([
|
||||||
|
[['test.spawn', { count: 8 }]],
|
||||||
|
[['test.wait', {}]],
|
||||||
|
[['test.spawn', { count: 4 }], ['test.spawn', { count: 2 }]],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
report.phases.map((p) => ({ phase: p.phase, key: p.key, steps: p.steps, draw: p.draw })),
|
||||||
|
[
|
||||||
|
{ phase: 0, key: 'phase1', steps: 1, draw: [{ dimension: 'test.creatures', total: 8 }] },
|
||||||
|
// A phase whose steps cost nothing still appears, so the rollup and the
|
||||||
|
// timeline have the same number of rows.
|
||||||
|
{ phase: 1, key: 'phase2', steps: 1, draw: [] },
|
||||||
|
{ phase: 2, key: 'phase3', steps: 2, draw: [{ dimension: 'test.creatures', total: 6 }] },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a phase is addressed by its ordinal, so an unnamed one still meters', async () => {
|
||||||
|
register([action('test.spawn', { cost: () => ({ 'test.creatures': 3 }) })], { budgets: ['test.creatures'] })
|
||||||
|
|
||||||
|
// The key a half-typed phase carries may not validate yet. A meter that could
|
||||||
|
// only address a phase whose key is already legal would go blank exactly while
|
||||||
|
// somebody is naming it.
|
||||||
|
const report = await price.priceSpec({ phases: [{ steps: [{ actionId: 'test.spawn', params: {} }] }] })
|
||||||
|
assert.equal(report.phases[0].phase, 0)
|
||||||
|
assert.equal(report.phases[0].key, null)
|
||||||
|
assert.equal(dimension(report, 'test.creatures').total, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The three ways a step cannot be priced ─────────────────────────────────
|
||||||
|
|
||||||
|
test('a step naming an action nothing registers is reported, not silently free', async () => {
|
||||||
|
register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
|
||||||
|
|
||||||
|
const report = await price.priceSpec(spec([[['test.spawn', {}], ['uo.creature.spawn', {}]]]))
|
||||||
|
|
||||||
|
assert.equal(report.steps, 2)
|
||||||
|
assert.equal(report.priced, 1)
|
||||||
|
assert.deepEqual(report.unpriced, [
|
||||||
|
{
|
||||||
|
phase: 0,
|
||||||
|
seq: 1,
|
||||||
|
actionId: 'uo.creature.spawn',
|
||||||
|
code: 'dormant',
|
||||||
|
message: 'no module registers "uo.creature.spawn"',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a step with no action chosen yet is not a problem', async () => {
|
||||||
|
register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
|
||||||
|
|
||||||
|
// A form being filled in, not a plan with a hole in it. Reporting it would put
|
||||||
|
// a red line on the screen for every step the moment it is added.
|
||||||
|
const report = await price.priceSpec(spec([[['test.spawn', {}], ['', {}]]]))
|
||||||
|
assert.deepEqual(report.unpriced, [])
|
||||||
|
assert.equal(report.steps, 2)
|
||||||
|
assert.equal(report.priced, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an action whose cost() breaks its own contract is unpriceable, not free', async () => {
|
||||||
|
register(
|
||||||
|
[
|
||||||
|
action('test.broken', {
|
||||||
|
label: 'Broken',
|
||||||
|
cost: () => {
|
||||||
|
throw new Error('nope')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{ budgets: [] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = await price.priceSpec(spec([[['test.broken', {}]]]))
|
||||||
|
assert.equal(report.priced, 0)
|
||||||
|
assert.equal(report.unpriced.length, 1)
|
||||||
|
assert.equal(report.unpriced[0].code, 'unpriceable')
|
||||||
|
assert.match(report.unpriced[0].message, /could not report what it costs/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an undeclared dimension is COUNTED and reported as unenforceable', async () => {
|
||||||
|
// The split `authorize.undeclaredDimensions` makes, for the same reason: the
|
||||||
|
// action really will try to spend it — the step is refused at dispatch for
|
||||||
|
// exactly this — so the amount is true and the enforcement is what is missing.
|
||||||
|
register([action('test.spawn', { cost: () => ({ 'test.creatures': 9 }) })], { budgets: [] })
|
||||||
|
|
||||||
|
const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
|
||||||
|
assert.equal(dimension(report, 'test.creatures').total, 9)
|
||||||
|
assert.equal(report.priced, 1)
|
||||||
|
assert.equal(report.unpriced[0].code, 'undeclared')
|
||||||
|
assert.match(report.unpriced[0].message, /refused at dispatch/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── What makes it safe to call while somebody types ────────────────────────
|
||||||
|
|
||||||
|
test('prices without dispatching: an action whose perform() throws still meters', async () => {
|
||||||
|
register(
|
||||||
|
[
|
||||||
|
action('test.spawn', {
|
||||||
|
cost: () => ({ 'test.creatures': 7 }),
|
||||||
|
perform: () => {
|
||||||
|
throw new Error('the shard is down')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{ budgets: ['test.creatures'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
|
||||||
|
assert.equal(dimension(report, 'test.creatures').total, 7)
|
||||||
|
assert.deepEqual(report.unpriced, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an empty plan prices to nothing rather than failing', async () => {
|
||||||
|
const report = await price.priceSpec({})
|
||||||
|
assert.deepEqual(report, { ok: true, steps: 0, priced: 0, cost: [], phases: [], unpriced: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The paste guard, which is the spec's own and not a number invented here ──
|
||||||
|
|
||||||
|
test('refuses a body over the spec size limits', async () => {
|
||||||
|
const spawn = { actionId: 'test.spawn', params: {} }
|
||||||
|
const tooManyPhases = { phases: Array.from({ length: 41 }, (_, i) => ({ key: `p${i}`, steps: [] })) }
|
||||||
|
assert.deepEqual(await price.priceSpec(tooManyPhases), { ok: false, error: 'at most 40 phases' })
|
||||||
|
|
||||||
|
const tooManySteps = { phases: [{ key: 'p', steps: Array.from({ length: 101 }, () => spawn) }] }
|
||||||
|
assert.deepEqual(await price.priceSpec(tooManySteps), {
|
||||||
|
ok: false,
|
||||||
|
error: 'at most 100 steps in one phase',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 40 x 100 is over MAX_STEPS while breaking neither of the two bounds above.
|
||||||
|
const tooManyOverall = {
|
||||||
|
phases: Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
key: `p${i}`,
|
||||||
|
steps: Array.from({ length: 100 }, () => spawn),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
assert.deepEqual(await price.priceSpec(tooManyOverall), {
|
||||||
|
ok: false,
|
||||||
|
error: 'at most 500 steps in one definition',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a step whose params are not an object is priced as no params rather than throwing', async () => {
|
||||||
|
register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 1 }) })], {
|
||||||
|
budgets: ['test.creatures'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const report = await price.priceSpec({
|
||||||
|
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', params: ['not', 'an', 'object'] }] }],
|
||||||
|
})
|
||||||
|
assert.equal(dimension(report, 'test.creatures').total, 1)
|
||||||
|
})
|
||||||
499
server/test/eventPublic.test.js
Normal file
499
server/test/eventPublic.test.js
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
// ── The public event surface (EVENTS_PLAN.md Phase 14a) ────────────────────
|
||||||
|
//
|
||||||
|
// The phase's shipped claim: **a visitor with no account sees the calendar, one
|
||||||
|
// event's page and an arc, and sees nothing an operator did not announce.**
|
||||||
|
//
|
||||||
|
// What is worth testing here is almost entirely the second half. The reads
|
||||||
|
// themselves are joins; the decisions are about what is absent:
|
||||||
|
//
|
||||||
|
// • a rehearsal, an unlisted definition and a draft are absent from every
|
||||||
|
// surface, and absent the same way — a 404 that cannot be told from a slug
|
||||||
|
// that never existed
|
||||||
|
// • the plan behind an event (phases, steps, actions, params) is never
|
||||||
|
// published; a live run carries the LABEL of its phase and nothing else
|
||||||
|
// • `failed` and `missed` are published as `cancelled`, because the difference
|
||||||
|
// between them is about the deployment rather than about the event
|
||||||
|
// • `member_key` never leaves the server, even on a results table
|
||||||
|
// • a participant's own history obeys the same two exclusions as the calendar,
|
||||||
|
// so attending an unannounced event does not disclose that it exists
|
||||||
|
//
|
||||||
|
// Stubbed at the `.db` layer, the shape `eventSchedule.test.js` uses.
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const publicModel = require('../src/model/events/eventPublic.model')
|
||||||
|
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||||
|
const runsDb = require('../src/model/events/eventRuns.db')
|
||||||
|
const seriesDb = require('../src/model/events/eventSeries.db')
|
||||||
|
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||||
|
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-01T12:00:00Z')
|
||||||
|
|
||||||
|
const SPEC = {
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [
|
||||||
|
{ id: 'muster', label: 'The muster', steps: [{ id: 's1', action: 'core.announce' }] },
|
||||||
|
{ id: 'assault', label: 'The assault', steps: [] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = {}
|
||||||
|
for (const [name, mod] of [
|
||||||
|
['definitionsDb', definitionsDb],
|
||||||
|
['runsDb', runsDb],
|
||||||
|
['seriesDb', seriesDb],
|
||||||
|
['versionsDb', versionsDb],
|
||||||
|
['participantsDb', participantsDb],
|
||||||
|
]) {
|
||||||
|
originals[name] = { mod, fns: { ...mod } }
|
||||||
|
}
|
||||||
|
const restoreOriginals = () => {
|
||||||
|
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store
|
||||||
|
|
||||||
|
const definition = (over = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
title: 'The Yew Invasion',
|
||||||
|
slug: 'the-yew-invasion',
|
||||||
|
summary: 'Orcish warbands are massing north of Yew.',
|
||||||
|
body: '<p>They came at dusk.</p>',
|
||||||
|
image_url: null,
|
||||||
|
state: 'ready',
|
||||||
|
listed: true,
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
series_id: null,
|
||||||
|
series_name: null,
|
||||||
|
series_slug: null,
|
||||||
|
current_version_id: 100,
|
||||||
|
spec: SPEC,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = (over = {}) => ({
|
||||||
|
id: 3692,
|
||||||
|
definition_id: 1,
|
||||||
|
version_id: 100,
|
||||||
|
scope: '',
|
||||||
|
status: 'completed',
|
||||||
|
// Every one of these is a field the public shapes must NOT carry. They are on
|
||||||
|
// the fixture on purpose: a `{ ...run }` anywhere in the model would publish
|
||||||
|
// them, and the assertions below are what would catch it.
|
||||||
|
health: 'degraded',
|
||||||
|
cleanup_status: 'incomplete',
|
||||||
|
claimed_by: 'worker-3',
|
||||||
|
claim_expires_at: new Date(),
|
||||||
|
last_error: 'sidecar responded 503',
|
||||||
|
current_phase: 'assault',
|
||||||
|
scheduled_for: new Date('2026-08-29T00:00:00Z'),
|
||||||
|
started_at: new Date('2026-08-29T00:00:05Z'),
|
||||||
|
ended_at: new Date('2026-08-29T01:30:00Z'),
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
rehearsal: false,
|
||||||
|
results_published_at: new Date('2026-08-29T02:00:00Z'),
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
function installStubs() {
|
||||||
|
definitionsDb.getPublicBySlug = async (slug) => {
|
||||||
|
const d = store.definitions.find((x) => x.slug === slug)
|
||||||
|
return d && d.state === 'ready' && d.listed ? d : undefined
|
||||||
|
}
|
||||||
|
definitionsDb.listPublicBySeries = async (seriesId) =>
|
||||||
|
store.definitions.filter((d) => d.series_id === seriesId && d.state === 'ready' && d.listed)
|
||||||
|
definitionsDb.findSchedulable = async ({ listedOnly = false } = {}) =>
|
||||||
|
store.definitions
|
||||||
|
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
|
||||||
|
.map((d) => ({ ...d, version_spec: d.spec }))
|
||||||
|
|
||||||
|
// Mirrors the OVERLAP predicate the real statement uses: a run is in the window
|
||||||
|
// if its instant falls inside it, OR if it began before the window and is still
|
||||||
|
// live. A run is an interval, not an instant — see `eventRuns.db.listInWindow`.
|
||||||
|
runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
|
||||||
|
store.runs.filter((r) => {
|
||||||
|
const at = new Date(r.scheduled_for)
|
||||||
|
const startsInside = at >= from && at < to
|
||||||
|
const liveAcross = at < to && ['starting', 'running', 'paused', 'ending'].includes(r.status)
|
||||||
|
if (!startsInside && !liveAcross) return false
|
||||||
|
if (!publicOnly) return true
|
||||||
|
const d = store.definitions.find((x) => x.id === r.definition_id)
|
||||||
|
return !r.rehearsal && d && d.listed && d.state !== 'archived'
|
||||||
|
})
|
||||||
|
runsDb.listPublicForDefinition = async (id) =>
|
||||||
|
store.runs
|
||||||
|
.filter((r) => r.definition_id === id && !r.rehearsal)
|
||||||
|
.sort((a, b) => new Date(b.scheduled_for) - new Date(a.scheduled_for))
|
||||||
|
|
||||||
|
seriesDb.getById = async (id) => store.series.find((s) => s.id === id) || null
|
||||||
|
seriesDb.getBySlug = async (slug) => store.series.find((s) => s.slug === slug) || null
|
||||||
|
versionsDb.getById = async (id) => (id === 100 ? { id, spec: SPEC } : null)
|
||||||
|
participantsDb.listForRun = async () => store.participants
|
||||||
|
participantsDb.listForUser = async () => store.history
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const d = definition()
|
||||||
|
store = {
|
||||||
|
definitions: [d],
|
||||||
|
// The joined shape `listInWindow` answers with.
|
||||||
|
runs: [{ ...run(), definition_title: d.title, definition_slug: d.slug }],
|
||||||
|
series: [],
|
||||||
|
participants: [],
|
||||||
|
history: [],
|
||||||
|
}
|
||||||
|
installStubs()
|
||||||
|
})
|
||||||
|
afterEach(restoreOriginals)
|
||||||
|
|
||||||
|
// ── The calendar ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a calendar entry carries no operational field at all', async () => {
|
||||||
|
const result = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
const [entry] = result.entries
|
||||||
|
assert.equal(entry.title, 'The Yew Invasion')
|
||||||
|
// The whole security property of this file, asserted positively: the entry has
|
||||||
|
// exactly these keys and gaining one is a deliberate act.
|
||||||
|
assert.deepEqual(Object.keys(entry).sort(), [
|
||||||
|
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the default window reaches back as well as forward', async () => {
|
||||||
|
// §I: this route is "upcoming, live and recent". The default used to start at
|
||||||
|
// `now`, which left no room for the third word — an event that finished an hour
|
||||||
|
// ago was already gone, so a visitor had nowhere to find the results of the
|
||||||
|
// thing they had just attended (Phase 16 walk).
|
||||||
|
const result = await publicModel.calendar({ now: NOW })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
const back = (NOW - new Date(result.window.from)) / 86_400_000
|
||||||
|
const forward = (new Date(result.window.to) - NOW) / 86_400_000
|
||||||
|
assert.equal(back, publicModel.DEFAULT_RECENT_DAYS)
|
||||||
|
assert.equal(forward, publicModel.DEFAULT_WINDOW_DAYS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run happening RIGHT NOW is on the calendar, whenever it started', async () => {
|
||||||
|
// The defect this pair was written for: the site said `live: true` on the
|
||||||
|
// event's own page and served `entries: []` from the calendar, because the
|
||||||
|
// window test read the START instant and a live run had already started. A run
|
||||||
|
// is an interval; the calendar asks which intervals overlap it.
|
||||||
|
store.runs = [
|
||||||
|
{
|
||||||
|
...run({
|
||||||
|
status: 'running',
|
||||||
|
// Well before any default window would begin.
|
||||||
|
scheduled_for: new Date('2026-08-01T00:00:00Z'),
|
||||||
|
ended_at: null,
|
||||||
|
}),
|
||||||
|
definition_title: 'The Yew Invasion',
|
||||||
|
definition_slug: 'the-yew-invasion',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = await publicModel.calendar({ now: NOW })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
const entry = result.entries.find((e) => e.kind === 'run')
|
||||||
|
assert.ok(entry, 'a live run must appear however long ago it began')
|
||||||
|
assert.equal(entry.live, true)
|
||||||
|
assert.equal(entry.status, 'live')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run that finished inside the recent tail is still on the calendar', async () => {
|
||||||
|
store.runs = [
|
||||||
|
{
|
||||||
|
...run({
|
||||||
|
status: 'completed',
|
||||||
|
scheduled_for: new Date(NOW.getTime() - 2 * 86_400_000),
|
||||||
|
ended_at: new Date(NOW.getTime() - 2 * 86_400_000 + 3_600_000),
|
||||||
|
}),
|
||||||
|
definition_title: 'The Yew Invasion',
|
||||||
|
definition_slug: 'the-yew-invasion',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = await publicModel.calendar({ now: NOW })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.entries.filter((e) => e.kind === 'run').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nothing is FORECAST into the recent tail', async () => {
|
||||||
|
// The tail is for what happened, and only the materialised half fills it. A
|
||||||
|
// projection into the past would advertise an occurrence that did not happen:
|
||||||
|
// one that WAS created is a real row and arrives as a run, and one that was not
|
||||||
|
// is a slot the runner has already gone past.
|
||||||
|
store.definitions = [
|
||||||
|
definition({
|
||||||
|
spec: {
|
||||||
|
...SPEC,
|
||||||
|
schedule: {
|
||||||
|
kind: 'weekly',
|
||||||
|
days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
|
||||||
|
time: '20:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
store.runs = []
|
||||||
|
const result = await publicModel.calendar({ now: NOW })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
const projected = result.entries.filter((e) => e.kind !== 'run')
|
||||||
|
assert.ok(projected.length > 0, 'a daily schedule must still forecast forwards')
|
||||||
|
for (const entry of projected) {
|
||||||
|
assert.ok(
|
||||||
|
new Date(entry.scheduledFor) >= NOW,
|
||||||
|
`forecast ${entry.scheduledFor} is before now — the tail must hold no projections`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a window wider than the cap is refused rather than served slowly', async () => {
|
||||||
|
const result = await publicModel.calendar({ from: '2026-01-01', to: '2026-12-31', now: NOW })
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a projection is not emitted for an instant a run already occupies', async () => {
|
||||||
|
// The definition recurs weekly on the Saturday its one run already sits on.
|
||||||
|
store.definitions[0].spec = {
|
||||||
|
...SPEC,
|
||||||
|
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
|
||||||
|
}
|
||||||
|
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-08-31', now: NOW })
|
||||||
|
const at = result.entries.filter(
|
||||||
|
(e) => new Date(e.scheduledFor).getTime() === new Date('2026-08-29T00:00:00Z').getTime(),
|
||||||
|
)
|
||||||
|
assert.equal(at.length, 1)
|
||||||
|
assert.equal(at[0].kind, 'run')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── What the public never sees ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('an unlisted event is absent from the calendar and 404s on its own page', async () => {
|
||||||
|
store.definitions[0].listed = false
|
||||||
|
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||||
|
assert.deepEqual(cal.entries, [])
|
||||||
|
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.ok, false)
|
||||||
|
assert.equal(page.status, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a draft answers exactly as an unlisted one does — indistinguishable from no such slug', async () => {
|
||||||
|
store.definitions[0].state = 'draft'
|
||||||
|
const draft = await publicModel.event('the-yew-invasion')
|
||||||
|
const missing = await publicModel.event('no-such-event')
|
||||||
|
assert.deepEqual(draft, missing)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a rehearsal is absent from the calendar and from an event page', async () => {
|
||||||
|
store.runs[0].rehearsal = true
|
||||||
|
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||||
|
assert.deepEqual(cal.entries, [])
|
||||||
|
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.ok, true)
|
||||||
|
assert.deepEqual(page.event.past, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an occurrence publishes no health, no cleanup state, no claim and no error', async () => {
|
||||||
|
store.runs[0].status = 'running'
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
const occurrence = page.event.current
|
||||||
|
for (const leaked of ['health', 'cleanupStatus', 'claimedBy', 'claimExpiresAt', 'lastError', 'versionId']) {
|
||||||
|
assert.equal(occurrence[leaked], undefined, `${leaked} must not be published`)
|
||||||
|
}
|
||||||
|
assert.equal(JSON.stringify(page).includes('sidecar responded 503'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a live run carries its phase LABEL, and never the spec behind it', async () => {
|
||||||
|
store.runs[0].status = 'running'
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.event.current.phase, 'The assault')
|
||||||
|
// The step ids in the fixture spec are the tell: if the spec were published
|
||||||
|
// anywhere in this answer, this would find it.
|
||||||
|
assert.equal(JSON.stringify(page).includes('core.announce'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a phase the pinned version does not name renders nothing rather than an id', async () => {
|
||||||
|
store.runs[0].status = 'running'
|
||||||
|
store.runs[0].current_phase = 'a-phase-since-renamed'
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.event.current.phase, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed and missed are both published as cancelled', async () => {
|
||||||
|
for (const status of ['failed', 'missed']) {
|
||||||
|
store.runs[0].status = status
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.event.past[0].status, 'cancelled', status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('paused is published as live — an operator holding a run is not a public state', async () => {
|
||||||
|
store.runs[0].status = 'paused'
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.event.current.status, 'live')
|
||||||
|
assert.equal(page.event.live, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Which side of now an occurrence falls on ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Both of these were found by the browser walk, and both are the same mistake:
|
||||||
|
// the split reading a STATUS where it should read a clock. Dates here are
|
||||||
|
// relative to the real clock, because `event()` asks `Date.now()` — a run
|
||||||
|
// "next Friday" has to still be next Friday when this runs.
|
||||||
|
|
||||||
|
const inDays = (n) => new Date(Date.now() + n * 86_400_000)
|
||||||
|
|
||||||
|
test('a cancelled occurrence in the FUTURE is what is coming, not what happened', async () => {
|
||||||
|
// It was announced and it has been called off, and "next Friday is off" is
|
||||||
|
// exactly what somebody checking the calendar came to find out. Filing it
|
||||||
|
// under "previously" tells them it already happened, which is the one thing
|
||||||
|
// certainly untrue about it.
|
||||||
|
store.runs = [
|
||||||
|
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
|
||||||
|
{ ...run({ id: 2, status: 'scheduled', scheduled_for: inDays(11), ended_at: null, results_published_at: null }) },
|
||||||
|
]
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.deepEqual(page.event.upcoming.map((o) => o.runId), [1, 2])
|
||||||
|
assert.deepEqual(page.event.past, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('`next` skips a cancelled occurrence even though it is listed as coming', async () => {
|
||||||
|
// The headline answers "when is the next one", and a cancelled occurrence is
|
||||||
|
// not one. An event whose only future occurrence was called off has no `next`
|
||||||
|
// and says so, while the cancellation is still listed below.
|
||||||
|
store.runs = [
|
||||||
|
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
|
||||||
|
]
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.event.next, null)
|
||||||
|
assert.equal(page.event.upcoming.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a scheduled occurrence whose moment has gone by is in the past', async () => {
|
||||||
|
// The other direction of the same rule: the runner had not reached it, so its
|
||||||
|
// status still says `scheduled` while the evening it named is over.
|
||||||
|
store.runs = [
|
||||||
|
{ ...run({ id: 1, status: 'scheduled', scheduled_for: inDays(-3), ended_at: null, results_published_at: null }) },
|
||||||
|
]
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.deepEqual(page.event.upcoming, [])
|
||||||
|
assert.deepEqual(page.event.past.map((o) => o.runId), [1])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Results ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a results row publishes the score and the rank and never the member key', async () => {
|
||||||
|
store.participants = [
|
||||||
|
{ id: 1, member_key: 'serial:0x40001234', user_id: 7, score: 1420, rank_at: 1, meta: { name: 'Aldric' } },
|
||||||
|
]
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.event.results.participants[0].name, 'Aldric')
|
||||||
|
assert.equal(page.event.results.participants[0].rank, 1)
|
||||||
|
assert.equal(JSON.stringify(page).includes('0x40001234'), false)
|
||||||
|
assert.equal(JSON.stringify(page).includes('user_id'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unpublished results table is absent rather than empty', async () => {
|
||||||
|
store.runs[0].results_published_at = null
|
||||||
|
store.participants = [{ id: 1, member_key: 'k', score: 10, rank_at: null, meta: null }]
|
||||||
|
const page = await publicModel.event('the-yew-invasion')
|
||||||
|
assert.equal(page.event.results, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('?run= selects which occurrence the results are about', async () => {
|
||||||
|
const older = {
|
||||||
|
...run({ id: 3600, scheduled_for: new Date('2026-08-22T00:00:00Z') }),
|
||||||
|
definition_title: 'The Yew Invasion',
|
||||||
|
definition_slug: 'the-yew-invasion',
|
||||||
|
}
|
||||||
|
store.runs.push(older)
|
||||||
|
const page = await publicModel.event('the-yew-invasion', { runId: '3600' })
|
||||||
|
assert.equal(page.event.results.runId, 3600)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run id belonging to no occurrence of this event renders the page anyway', async () => {
|
||||||
|
// A stale link in a months-old mail should land on the event it was about, not
|
||||||
|
// on a dead end.
|
||||||
|
const page = await publicModel.event('the-yew-invasion', { runId: '999999' })
|
||||||
|
assert.equal(page.ok, true)
|
||||||
|
assert.equal(page.event.slug, 'the-yew-invasion')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The arc ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a series with nothing listed in it is a 404, not an empty page', async () => {
|
||||||
|
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: null }]
|
||||||
|
store.definitions[0].series_id = 5
|
||||||
|
store.definitions[0].listed = false
|
||||||
|
const arc = await publicModel.series('the-yew-campaign')
|
||||||
|
assert.equal(arc.ok, false)
|
||||||
|
assert.equal(arc.status, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an arc lists its listed events and nothing about their plans', async () => {
|
||||||
|
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: 'An arc.' }]
|
||||||
|
store.definitions[0].series_id = 5
|
||||||
|
const arc = await publicModel.series('the-yew-campaign')
|
||||||
|
assert.equal(arc.ok, true)
|
||||||
|
assert.deepEqual(Object.keys(arc.series.events[0]).sort(), ['imageUrl', 'slug', 'summary', 'title'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Participation history ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('history publishes the rank as null until results were published', async () => {
|
||||||
|
store.history = [
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
run_id: 3692,
|
||||||
|
score: 1420,
|
||||||
|
rank_at: null,
|
||||||
|
joined_at: new Date(),
|
||||||
|
meta: null,
|
||||||
|
scheduled_for: new Date('2026-08-29T00:00:00Z'),
|
||||||
|
started_at: null,
|
||||||
|
ended_at: null,
|
||||||
|
status: 'completed',
|
||||||
|
scope: '',
|
||||||
|
timezone: 'UTC',
|
||||||
|
results_published_at: null,
|
||||||
|
definition_title: 'The Yew Invasion',
|
||||||
|
definition_slug: 'the-yew-invasion',
|
||||||
|
series_name: null,
|
||||||
|
series_slug: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = await publicModel.history(7)
|
||||||
|
assert.equal(result.entries[0].rank, null)
|
||||||
|
assert.equal(result.entries[0].resultsPublishedAt, null)
|
||||||
|
assert.equal(result.entries[0].score, 1420)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('history publishes no member key', async () => {
|
||||||
|
store.history = [
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
run_id: 3692,
|
||||||
|
member_key: 'serial:0x40001234',
|
||||||
|
score: 1,
|
||||||
|
rank_at: 1,
|
||||||
|
joined_at: new Date(),
|
||||||
|
meta: null,
|
||||||
|
scheduled_for: new Date(),
|
||||||
|
status: 'completed',
|
||||||
|
timezone: 'UTC',
|
||||||
|
definition_title: 't',
|
||||||
|
definition_slug: 's',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = await publicModel.history(7)
|
||||||
|
assert.equal(JSON.stringify(result).includes('0x40001234'), false)
|
||||||
|
})
|
||||||
273
server/test/eventRecurrence.test.js
Normal file
273
server/test/eventRecurrence.test.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// ── Occurrence arithmetic (EVENTS.md §E, Phase 4) ──────────────────────────
|
||||||
|
//
|
||||||
|
// The plan asks for DST-crossing cases as EXPLICIT FIXTURES, and this file is
|
||||||
|
// them. The reason it is worth a test file of its own is that every failure here
|
||||||
|
// is silent in production: an event computed an hour off, or dropped for one
|
||||||
|
// week a year, looks exactly like an event that happened correctly until an
|
||||||
|
// operator is standing in the wrong place at the wrong time.
|
||||||
|
//
|
||||||
|
// The zone data is Node's own tzdata behind `Intl`, so these fixtures assert
|
||||||
|
// against the real transitions rather than against a hand-written offset table:
|
||||||
|
//
|
||||||
|
// • Europe/Berlin, 2026-03-29 — CET (+1) to CEST (+2). 02:00 to 03:00 does not
|
||||||
|
// exist. A weekly 02:30 event is the case the org lead decided.
|
||||||
|
// • Europe/Berlin, 2026-10-25 — CEST (+2) back to CET (+1). 02:00 to 03:00
|
||||||
|
// happens twice.
|
||||||
|
// • Asia/Kolkata — +05:30, no DST at all, and a half-hour offset, which is
|
||||||
|
// what catches an implementation that assumed whole hours.
|
||||||
|
// • Australia/Lord_Howe — a THIRTY-MINUTE DST shift, which is what catches one
|
||||||
|
// that assumed the gap is always an hour.
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const r = require('../src/events/recurrence')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
/** What an instant reads as on the wall in a zone — the assertion that matters. */
|
||||||
|
const wall = (zone, at) => {
|
||||||
|
const p = r.wallPartsAt(zone, at instanceof Date ? at.getTime() : at)
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const walls = (occurrences, zone) => occurrences.map((o) => wall(zone, o.at))
|
||||||
|
|
||||||
|
// ── The rule the whole feature rests on ────────────────────────────────────
|
||||||
|
|
||||||
|
test('a weekly event keeps its LOCAL time across a DST boundary', () => {
|
||||||
|
// The single most important assertion in this file. Friday 20:00 in Berlin is
|
||||||
|
// 19:00 UTC in winter and 18:00 UTC in summer, and it is 20:00 on the wall on
|
||||||
|
// every one of those Fridays. A recurrence computed in UTC would put half the
|
||||||
|
// year an hour out, which is exactly what §E forbids.
|
||||||
|
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
||||||
|
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
|
||||||
|
|
||||||
|
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
||||||
|
'2026-03-20 20:00',
|
||||||
|
'2026-03-27 20:00',
|
||||||
|
'2026-04-03 20:00',
|
||||||
|
'2026-04-10 20:00',
|
||||||
|
])
|
||||||
|
// And the UTC instants really did move, which is what proves the zone was
|
||||||
|
// consulted rather than the arithmetic accidentally agreeing.
|
||||||
|
assert.equal(found[1].at.toISOString(), '2026-03-27T19:00:00.000Z')
|
||||||
|
assert.equal(found[2].at.toISOString(), '2026-04-03T18:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a weekly event keeps its local time across the October transition too', () => {
|
||||||
|
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
||||||
|
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 20), Date.UTC(2026, 10, 7))
|
||||||
|
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
||||||
|
'2026-10-23 20:00',
|
||||||
|
'2026-10-30 20:00',
|
||||||
|
'2026-11-06 20:00',
|
||||||
|
])
|
||||||
|
assert.equal(found[0].at.toISOString(), '2026-10-23T18:00:00.000Z')
|
||||||
|
assert.equal(found[1].at.toISOString(), '2026-10-30T19:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The two DST rules, as decided ──────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a local time the spring gap swallows moves FORWARD to the first one that exists', () => {
|
||||||
|
// 2026-03-29 in Berlin: 02:00 becomes 03:00 and 02:30 never happens. The
|
||||||
|
// decision is the first valid instant — 03:00 — rather than "shift by the gap"
|
||||||
|
// (03:30): the event happens as close to the authored time as the calendar
|
||||||
|
// allows.
|
||||||
|
const resolved = r.resolveWall('Europe/Berlin', 2026, 3, 29, 2, 30)
|
||||||
|
assert.equal(resolved.adjusted, 'gap')
|
||||||
|
assert.equal(wall('Europe/Berlin', resolved.at), '2026-03-29 03:00')
|
||||||
|
assert.equal(resolved.at.toISOString(), '2026-03-29T01:00:00.000Z')
|
||||||
|
assert.equal(resolved.shiftMinutes, 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a local time that happens twice takes the FIRST of them', () => {
|
||||||
|
// 2026-10-25 in Berlin: 02:30 comes round at 00:30Z (+2, still CEST) and again
|
||||||
|
// at 01:30Z (+1, now CET). The first is the answer, and the second must not be
|
||||||
|
// — an event that fired at the later one would be an hour late by the clock
|
||||||
|
// the author wrote it against.
|
||||||
|
const resolved = r.resolveWall('Europe/Berlin', 2026, 10, 25, 2, 30)
|
||||||
|
assert.equal(resolved.adjusted, 'ambiguous')
|
||||||
|
assert.equal(resolved.at.toISOString(), '2026-10-25T00:30:00.000Z')
|
||||||
|
assert.equal(wall('Europe/Berlin', resolved.at), '2026-10-25 02:30')
|
||||||
|
|
||||||
|
// Both instants really do read 02:30 — the fixture is only meaningful if the
|
||||||
|
// ambiguity is real.
|
||||||
|
const both = r.instantsForWall('Europe/Berlin', Date.UTC(2026, 9, 25, 2, 30))
|
||||||
|
assert.equal(both.length, 2)
|
||||||
|
assert.equal(both[0], Date.UTC(2026, 9, 25, 0, 30))
|
||||||
|
assert.equal(both[1], Date.UTC(2026, 9, 25, 1, 30))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a weekly event in the gap still happens that week — it is never dropped', () => {
|
||||||
|
// The rule that makes the gap decision worth having. A Sunday 02:30 event in
|
||||||
|
// Berlin happens on 29 March like every other Sunday; it simply happens at
|
||||||
|
// 03:00.
|
||||||
|
const schedule = { kind: 'weekly', days: ['sunday'], time: '02:30' }
|
||||||
|
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 6))
|
||||||
|
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
||||||
|
'2026-03-22 02:30',
|
||||||
|
'2026-03-29 03:00',
|
||||||
|
'2026-04-05 02:30',
|
||||||
|
])
|
||||||
|
assert.equal(found[1].adjusted, 'gap')
|
||||||
|
assert.equal(found[0].adjusted, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a thirty-minute DST shift resolves too — the gap is not always an hour', () => {
|
||||||
|
// Lord Howe Island shifts by 30 minutes (+10:30 to +11:00). 2026-10-04 has no
|
||||||
|
// 02:15 local. An implementation that assumed a whole-hour gap gets this wrong.
|
||||||
|
const resolved = r.resolveWall('Australia/Lord_Howe', 2026, 10, 4, 2, 15)
|
||||||
|
assert.equal(resolved.adjusted, 'gap')
|
||||||
|
assert.equal(wall('Australia/Lord_Howe', resolved.at), '2026-10-04 02:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a zone with no DST at all is left completely alone', () => {
|
||||||
|
// Asia/Kolkata is +05:30 all year, and the half hour is the point: an
|
||||||
|
// implementation carrying whole-hour offsets around would be 30 minutes out
|
||||||
|
// here, every day, without any transition to blame.
|
||||||
|
const schedule = { kind: 'weekly', days: ['friday'], time: '19:30' }
|
||||||
|
const found = r.occurrencesBetween(schedule, 'Asia/Kolkata', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
|
||||||
|
assert.equal(found.length, 4)
|
||||||
|
for (const o of found) {
|
||||||
|
assert.equal(o.adjusted, null)
|
||||||
|
assert.equal(wall('Asia/Kolkata', o.at).slice(11), '19:30')
|
||||||
|
assert.equal(o.at.toISOString().slice(11, 16), '14:00')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The shapes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('`once` produces its single occurrence, and only inside the window', () => {
|
||||||
|
const schedule = { kind: 'once', at: '2026-10-31T20:00' }
|
||||||
|
const inside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1))
|
||||||
|
assert.equal(inside.length, 1)
|
||||||
|
assert.equal(wall('Europe/Berlin', inside[0].at), '2026-10-31 20:00')
|
||||||
|
|
||||||
|
const outside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1))
|
||||||
|
assert.deepEqual(outside, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('`weekly` honours every named day, in week order', () => {
|
||||||
|
const schedule = { kind: 'weekly', days: ['saturday', 'wednesday'], time: '18:00' }
|
||||||
|
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 15))
|
||||||
|
assert.deepEqual(walls(found, 'UTC'), [
|
||||||
|
'2026-06-03 18:00',
|
||||||
|
'2026-06-06 18:00',
|
||||||
|
'2026-06-10 18:00',
|
||||||
|
'2026-06-13 18:00',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('`monthly` with nth: -1 is the LAST weekday, which is not always the fourth', () => {
|
||||||
|
// The whole reason -1 exists. May 2026 has five Fridays and July 2026 has five;
|
||||||
|
// in those months "last" and "fourth" are different days, and a fishing contest
|
||||||
|
// on the last Friday is exactly that shape.
|
||||||
|
const last = r.occurrencesBetween(
|
||||||
|
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:00' },
|
||||||
|
'UTC',
|
||||||
|
Date.UTC(2026, 4, 1),
|
||||||
|
Date.UTC(2026, 8, 1),
|
||||||
|
)
|
||||||
|
const fourth = r.occurrencesBetween(
|
||||||
|
{ kind: 'monthly', nth: 4, weekday: 'friday', time: '19:00' },
|
||||||
|
'UTC',
|
||||||
|
Date.UTC(2026, 4, 1),
|
||||||
|
Date.UTC(2026, 8, 1),
|
||||||
|
)
|
||||||
|
// August 2026 has four Fridays, so "last" and "fourth" agree there and
|
||||||
|
// disagree in May and July. That the two lists share a member is the point:
|
||||||
|
// -1 is not a synonym for 4, and it is not a synonym for "different" either.
|
||||||
|
assert.deepEqual(walls(last, 'UTC'), [
|
||||||
|
'2026-05-29 19:00',
|
||||||
|
'2026-06-26 19:00',
|
||||||
|
'2026-07-31 19:00',
|
||||||
|
'2026-08-28 19:00',
|
||||||
|
])
|
||||||
|
assert.deepEqual(walls(fourth, 'UTC'), [
|
||||||
|
'2026-05-22 19:00',
|
||||||
|
'2026-06-26 19:00',
|
||||||
|
'2026-07-24 19:00',
|
||||||
|
'2026-08-28 19:00',
|
||||||
|
])
|
||||||
|
assert.notDeepEqual(walls(last, 'UTC'), walls(fourth, 'UTC'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every month has a first through fourth of every weekday', () => {
|
||||||
|
// The claim the closed set rests on: because there is no `nth: 5`, there is no
|
||||||
|
// absent-occurrence case to define. Checked across three years rather than
|
||||||
|
// asserted in a comment.
|
||||||
|
for (let year = 2026; year <= 2028; year += 1) {
|
||||||
|
for (let month = 1; month <= 12; month += 1) {
|
||||||
|
for (let weekday = 0; weekday <= 6; weekday += 1) {
|
||||||
|
for (const nth of [1, 2, 3, 4, -1]) {
|
||||||
|
const day = r.nthWeekdayDay(year, month, weekday, nth)
|
||||||
|
assert.ok(day, `${year}-${month} weekday ${weekday} nth ${nth} should exist`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('`manual` is not a recurrence and expands to nothing', () => {
|
||||||
|
assert.deepEqual(r.occurrencesBetween({ kind: 'manual' }, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Bounds and refusals ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('an inverted or empty window answers with nothing rather than throwing', () => {
|
||||||
|
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
||||||
|
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 4, 1)), [])
|
||||||
|
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 1)), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a malformed schedule expands to nothing rather than to a wrong instant', () => {
|
||||||
|
// These shapes cannot come through `spec.js`, but they can come from a row
|
||||||
|
// written directly into the database — and the runner must not turn one into a
|
||||||
|
// world change at an invented time.
|
||||||
|
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: [], time: '20:00' }, 'UTC', 0, 1e12), [])
|
||||||
|
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: ['friday'], time: '25:00' }, 'UTC', 0, 1e12), [])
|
||||||
|
assert.deepEqual(r.occurrencesBetween({ kind: 'monthly', nth: 9, weekday: 'friday', time: '19:00' }, 'UTC', 0, 1e12), [])
|
||||||
|
assert.deepEqual(r.occurrencesBetween({ kind: 'once', at: 'tomorrow' }, 'UTC', 0, 1e12), [])
|
||||||
|
assert.deepEqual(r.occurrencesBetween(null, 'UTC', 0, 1e12), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the expansion is bounded, so a wide window cannot become an outage', () => {
|
||||||
|
const schedule = {
|
||||||
|
kind: 'weekly',
|
||||||
|
days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
|
||||||
|
time: '12:00',
|
||||||
|
}
|
||||||
|
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2020, 0, 1), Date.UTC(2030, 0, 1))
|
||||||
|
assert.equal(found.length, r.MAX_OCCURRENCES)
|
||||||
|
|
||||||
|
const smaller = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1), { limit: 10 })
|
||||||
|
assert.equal(smaller.length, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nextOccurrence looks forward and finds nothing when there is nothing', () => {
|
||||||
|
const weekly = r.nextOccurrence({ kind: 'weekly', days: ['friday'], time: '20:00' }, 'UTC', Date.UTC(2026, 5, 1))
|
||||||
|
assert.equal(wall('UTC', weekly.at), '2026-06-05 20:00')
|
||||||
|
|
||||||
|
// A `once` already in the past has no next occurrence, which is what stops a
|
||||||
|
// one-off event being re-materialised for ever.
|
||||||
|
assert.equal(r.nextOccurrence({ kind: 'once', at: '2020-01-01T12:00' }, 'UTC', Date.UTC(2026, 5, 1)), null)
|
||||||
|
assert.equal(r.nextOccurrence({ kind: 'manual' }, 'UTC', Date.UTC(2026, 5, 1)), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe says the schedule back in words, in the event own zone', () => {
|
||||||
|
assert.equal(
|
||||||
|
r.describe({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
|
||||||
|
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
r.describe({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
|
||||||
|
'The last Friday of every month at 19:30 (Asia/Kolkata)',
|
||||||
|
)
|
||||||
|
assert.equal(r.describe({ kind: 'manual' }), 'Started by hand')
|
||||||
|
})
|
||||||
739
server/test/eventRunControls.test.js
Normal file
739
server/test/eventRunControls.test.js
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
// ── The live run controls (EVENTS_PLAN.md Phase 3) ─────────────────────────
|
||||||
|
//
|
||||||
|
// Seven controls as of Phase 5, and what is tested is almost entirely the
|
||||||
|
// REFUSALS. A control
|
||||||
|
// that works is easy; a control that works from a status it should not have
|
||||||
|
// worked from is a staff member changing a live game world by pressing a button
|
||||||
|
// a stale screen offered them. So each of the six is exercised from every status
|
||||||
|
// it must decline, and the four that a run console could plausibly offer wrongly
|
||||||
|
// get a test of their own:
|
||||||
|
//
|
||||||
|
// • retry on a step the run has already moved past (the `skip` disposition) —
|
||||||
|
// the test that found the first draft's guard was reading the wrong end of
|
||||||
|
// the phase
|
||||||
|
// • confirm on a step a process is mid-dispatch on, not a parked cue
|
||||||
|
// • skip on a step with a live lease
|
||||||
|
// • cancel closing out a parked cue, so a cancelled run stops "waiting"
|
||||||
|
// • advance on a phase that is NOT waiting on its gate — the refusal that
|
||||||
|
// makes force-advance an override rather than a way to skip a phase's steps
|
||||||
|
//
|
||||||
|
// The three tables are stubbed at the `.db` layer and the model's own logic runs
|
||||||
|
// for real against them — the shape `eventRunner.test.js` uses. What a stub
|
||||||
|
// cannot prove is that the five statements mean this against a real server; the
|
||||||
|
// guards that are pure SQL (`status = 'running' AND claim_expires_at IS NULL`
|
||||||
|
// and `lastStartedSeq`'s MAX) are proved in `eventRunnerSql.test.js`.
|
||||||
|
//
|
||||||
|
// Point the DB at a closed port before requiring anything.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const controls = require('../src/model/events/eventRunControls.model')
|
||||||
|
const runsDb = require('../src/model/events/eventRuns.db')
|
||||||
|
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||||
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
|
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||||
|
// Phase 8: cancel now decides what happens to the run's world changes, and
|
||||||
|
// `cleanupRun` is the eighth control. Same rule as every phase since the fourth —
|
||||||
|
// a new leg under a model needs a stub in every file that stubs that layer.
|
||||||
|
const resourcesDb = require('../src/model/events/eventRunResources.db')
|
||||||
|
const eventCleanup = require('../src/events/cleanup')
|
||||||
|
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||||
|
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||||
|
const engagementEmit = require('../src/utils/engagementEmit')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
|
||||||
|
const ACTOR = 7
|
||||||
|
|
||||||
|
let store
|
||||||
|
const originals = [
|
||||||
|
['runs', runsDb, { ...runsDb }],
|
||||||
|
['steps', stepsDb, { ...stepsDb }],
|
||||||
|
['log', logDb, { ...logDb }],
|
||||||
|
['gates', gatesDb, { ...gatesDb }],
|
||||||
|
['resources', resourcesDb, { ...resourcesDb }],
|
||||||
|
['cleanup', eventCleanup, { ...eventCleanup }],
|
||||||
|
// Phase 10: `cancel` now announces, and `events/announce.js` reads the
|
||||||
|
// definition. Left unstubbed every cancel here would wait out the dead-port
|
||||||
|
// pool. Stubbed rather than silenced, so the announce path runs for real and
|
||||||
|
// `store.emits` can assert it fired after the guarded transition and not
|
||||||
|
// before it.
|
||||||
|
['definitions', definitionsDb, { ...definitionsDb }],
|
||||||
|
['participants', participantsDb, { ...participantsDb }],
|
||||||
|
['emit', engagementEmit, { ...engagementEmit }],
|
||||||
|
]
|
||||||
|
|
||||||
|
function installStubs() {
|
||||||
|
store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, emits: [], nextStepId: 1, nextGateId: 1 }
|
||||||
|
definitionsDb.getById = async (id) => ({ id, title: `definition ${id}`, summary: null, series_name: null, timezone: 'UTC' })
|
||||||
|
participantsDb.countForRun = async () => 0
|
||||||
|
engagementEmit.emit = (owner, triggerId, envelope) => {
|
||||||
|
store.emits.push({ owner, triggerId, envelope })
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
const snap = (o) => ({ ...o })
|
||||||
|
|
||||||
|
runsDb.setCleanupStatus = async (id, to, from = null) => {
|
||||||
|
const r = store.runs.get(Number(id))
|
||||||
|
if (!r) return false
|
||||||
|
if (from && !from.includes(r.cleanup_status)) return false
|
||||||
|
r.cleanup_status = to
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sweep itself is `eventCleanup.test.js`'s subject. What this file is
|
||||||
|
// about is which control calls it, with what, and whether it is allowed to.
|
||||||
|
eventCleanup.cleanupRun = async (run, opts = {}) => {
|
||||||
|
store.sweeps.push({ runId: run.id, ...opts })
|
||||||
|
return { attempted: 1, reverted: 1, drifted: 0, failed: 0, remaining: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel asks the LEDGER whether the run owes the world anything, so that
|
||||||
|
// `cleanup: false` on a run with nothing recorded does not stamp `incomplete`
|
||||||
|
// over `not_required`. Unstubbed this is the ten-second dead-port wait, for the
|
||||||
|
// fifth time in this feature.
|
||||||
|
resourcesDb.unresolvedCount = async (runId) => store.unresolved[runId] ?? 0
|
||||||
|
|
||||||
|
runsDb.getById = async (id) => {
|
||||||
|
const r = store.runs.get(Number(id))
|
||||||
|
return r ? snap(r) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
runsDb.transition = async (id, from, to, opts = {}) => {
|
||||||
|
const r = store.runs.get(Number(id))
|
||||||
|
const froms = Array.isArray(from) ? from : [from]
|
||||||
|
if (!r || !froms.includes(r.status)) return false
|
||||||
|
r.status = to
|
||||||
|
if (opts.phase !== undefined) r.current_phase = opts.phase
|
||||||
|
if (opts.error !== undefined) r.last_error = opts.error
|
||||||
|
if (TERMINAL.includes(to) || opts.clearClaim) {
|
||||||
|
r.claimed_by = null
|
||||||
|
r.claim_expires_at = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
stepsDb.getById = async (id) => {
|
||||||
|
const s = store.steps.get(Number(id))
|
||||||
|
return s ? snap(s) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each of the four mirrors its statement's WHERE clause exactly. A stub can
|
||||||
|
// only ever agree with whoever wrote it, so what these buy is the model's
|
||||||
|
// logic around them; the clauses themselves are checked against a real server.
|
||||||
|
stepsDb.confirmParked = async (id, note) => {
|
||||||
|
const s = store.steps.get(Number(id))
|
||||||
|
if (!s || s.status !== 'running' || s.claim_expires_at) return false
|
||||||
|
Object.assign(s, { status: 'done', last_error: note, claimed_by: null })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
stepsDb.skipByHuman = async (id, reason) => {
|
||||||
|
const s = store.steps.get(Number(id))
|
||||||
|
if (!s) return false
|
||||||
|
const ok = s.status === 'pending' || (s.status === 'running' && !s.claim_expires_at)
|
||||||
|
if (!ok) return false
|
||||||
|
Object.assign(s, { status: 'skipped', last_error: reason, claimed_by: null })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
stepsDb.requeue = async (id) => {
|
||||||
|
const s = store.steps.get(Number(id))
|
||||||
|
if (!s || s.status !== 'failed') return false
|
||||||
|
Object.assign(s, { status: 'pending', attempts: 0, due_at: null, last_error: null, claimed_by: null, claim_expires_at: null })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
stepsDb.cancelOpen = async (runId) => {
|
||||||
|
let n = 0
|
||||||
|
for (const s of store.steps.values()) {
|
||||||
|
if (s.run_id !== Number(runId)) continue
|
||||||
|
if (s.status === 'pending' || (s.status === 'running' && !s.claim_expires_at)) {
|
||||||
|
s.status = 'cancelled'
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5's advance guard reads this. Unstubbed it is a real query, and this
|
||||||
|
// file points the pool at a closed port — three tests would hang for ten
|
||||||
|
// seconds each and then fail with ECONNREFUSED rather than saying anything
|
||||||
|
// about the control. Same shape as the statement: pending and running only,
|
||||||
|
// lowest seq first.
|
||||||
|
stepsDb.nextOpenStep = async (runId, phase) => {
|
||||||
|
const s = [...store.steps.values()]
|
||||||
|
.filter((x) => x.run_id === Number(runId) && x.phase === phase && ['pending', 'running'].includes(x.status))
|
||||||
|
.sort((a, b) => a.seq - b.seq || a.id - b.id)[0]
|
||||||
|
return s ? { ...s } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
stepsDb.lastStartedSeq = async (runId, phase) => {
|
||||||
|
const started = [...store.steps.values()]
|
||||||
|
.filter((s) => s.run_id === Number(runId) && s.phase === phase && s.status !== 'pending')
|
||||||
|
.map((s) => s.seq)
|
||||||
|
return started.length ? Math.max(...started) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
logDb.write = async (line) => {
|
||||||
|
store.log.push(line)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5. `satisfy` carries `WHERE satisfied_at IS NULL`, and the stub keeps
|
||||||
|
// it: a gate that could be forced twice would log two advances of one phase.
|
||||||
|
gatesDb.forPhase = async (runId, phase) => {
|
||||||
|
const g = store.gates.get(`${Number(runId)}|${phase}`)
|
||||||
|
return g ? { ...g } : null
|
||||||
|
}
|
||||||
|
gatesDb.satisfy = async (id, by, { userId = null, now = new Date() } = {}) => {
|
||||||
|
const g = [...store.gates.values()].find((x) => x.id === id)
|
||||||
|
if (!g || g.satisfied_at) return false
|
||||||
|
Object.assign(g, { satisfied_at: now, satisfied_by: by, forced_by: userId })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(installStubs)
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [, mod, fns] of originals) Object.assign(mod, fns)
|
||||||
|
})
|
||||||
|
|
||||||
|
let nextRunId = 1
|
||||||
|
|
||||||
|
function seedGate(runId, phase, { kind = 'on', trigger = 'test.trigger', needed = 1, tally = 0, minutesAgo = 5 } = {}) {
|
||||||
|
const g = {
|
||||||
|
id: store.nextGateId++,
|
||||||
|
run_id: runId,
|
||||||
|
phase,
|
||||||
|
kind,
|
||||||
|
after_seconds: kind === 'after' ? 1800 : null,
|
||||||
|
trigger_id: kind === 'on' ? trigger : null,
|
||||||
|
conditions: null,
|
||||||
|
needed,
|
||||||
|
tally,
|
||||||
|
entered_at: new Date(Date.now() - minutesAgo * 60_000),
|
||||||
|
due_at: null,
|
||||||
|
last_event: null,
|
||||||
|
last_event_at: null,
|
||||||
|
satisfied_at: null,
|
||||||
|
satisfied_by: null,
|
||||||
|
forced_by: null,
|
||||||
|
}
|
||||||
|
store.gates.set(`${runId}|${phase}`, g)
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedRun({ status = 'running', phase = 'main', steps = [], cleanupStatus = 'not_required' } = {}) {
|
||||||
|
const id = nextRunId++
|
||||||
|
store.runs.set(id, {
|
||||||
|
id,
|
||||||
|
definition_id: id,
|
||||||
|
version_id: id,
|
||||||
|
status,
|
||||||
|
health: 'ok',
|
||||||
|
cleanup_status: cleanupStatus,
|
||||||
|
current_phase: phase,
|
||||||
|
claimed_by: null,
|
||||||
|
claim_expires_at: null,
|
||||||
|
last_error: null,
|
||||||
|
})
|
||||||
|
steps.forEach((s, i) => {
|
||||||
|
const stepId = store.nextStepId++
|
||||||
|
store.steps.set(stepId, {
|
||||||
|
id: stepId,
|
||||||
|
run_id: id,
|
||||||
|
phase: s.phase || phase,
|
||||||
|
seq: s.seq ?? i,
|
||||||
|
action_id: s.actionId || 'test.action',
|
||||||
|
status: s.status || 'pending',
|
||||||
|
attempts: s.attempts ?? 0,
|
||||||
|
due_at: null,
|
||||||
|
claimed_by: s.leased ? 'someone' : null,
|
||||||
|
claim_expires_at: s.leased ? new Date(Date.now() + 60_000) : null,
|
||||||
|
last_error: null,
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRow = (id) => store.runs.get(id)
|
||||||
|
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
|
||||||
|
const lastLog = () => store.log[store.log.length - 1]
|
||||||
|
|
||||||
|
// ── advance (Phase 5) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('advance releases a phase that is genuinely waiting on its gate', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||||
|
const gate = seedGate(id, 'main', { needed: 3, tally: 1, minutesAgo: 68 })
|
||||||
|
|
||||||
|
const result = await controls.advancePhase(id, { reason: 'the boss never spawned' }, ACTOR)
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.phase, 'main')
|
||||||
|
assert.equal(gate.satisfied_by, 'forced')
|
||||||
|
assert.equal(gate.forced_by, ACTOR)
|
||||||
|
|
||||||
|
// The run is NOT transitioned here: the next tick does the phase boundary,
|
||||||
|
// exactly as it does after resume, so there is one implementation of what a
|
||||||
|
// phase boundary is rather than two.
|
||||||
|
assert.equal(runRow(id).current_phase, 'main')
|
||||||
|
|
||||||
|
const line = lastLog()
|
||||||
|
assert.equal(line.kind, 'phase.advanced')
|
||||||
|
assert.equal(line.detail.because, 'forced')
|
||||||
|
assert.equal(line.detail.by, ACTOR)
|
||||||
|
assert.equal(line.detail.reason, 'the boss never spawned')
|
||||||
|
assert.equal(line.detail.seen, 1)
|
||||||
|
assert.equal(line.detail.needed, 3, 'and the log says what it was still waiting for')
|
||||||
|
assert.ok(line.detail.waitedSeconds > 4000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance is refused on a phase that is waiting on a STEP, not on its gate', async () => {
|
||||||
|
// The refusal that makes this an override rather than a way to skip work: a
|
||||||
|
// phase with an open step is held by the step, and skip is its control.
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }, { status: 'pending', actionId: 'test.slow' }] })
|
||||||
|
seedGate(id, 'main')
|
||||||
|
|
||||||
|
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 409)
|
||||||
|
assert.match(result.errors[0], /waiting on step 1 \(test\.slow\)/)
|
||||||
|
assert.equal(store.log.length, 0, 'and nothing is logged for a refusal')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance is refused when the phase has no advance condition at all', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||||
|
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.errors[0], /has no advance condition; skip its steps instead/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance is refused from every status that is not `running`', async () => {
|
||||||
|
for (const status of ['scheduled', 'starting', 'paused', 'ending', ...TERMINAL]) {
|
||||||
|
const id = seedRun({ status, steps: [{ status: 'done' }] })
|
||||||
|
seedGate(id, 'main')
|
||||||
|
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false, `a ${status} run should not be advanceable`)
|
||||||
|
assert.match(result.errors[0], new RegExp(`a ${status} run has no phase to advance`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance twice is refused the second time', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||||
|
seedGate(id, 'main')
|
||||||
|
assert.equal((await controls.advancePhase(id, {}, ACTOR)).ok, true)
|
||||||
|
const second = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(second.ok, false)
|
||||||
|
assert.match(second.errors[0], /already past its advance condition/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── pause / resume ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('pause takes a run in flight and records who did it', async () => {
|
||||||
|
const id = seedRun({ status: 'running' })
|
||||||
|
const result = await controls.pause(id, { reason: 'the shard is lagging' }, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(runRow(id).status, 'paused')
|
||||||
|
assert.deepEqual(lastLog().detail, {
|
||||||
|
from: 'running',
|
||||||
|
to: 'paused',
|
||||||
|
control: 'pause',
|
||||||
|
by: ACTOR,
|
||||||
|
reason: 'the shard is lagging',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pause drops the claim, so the next tick is not locked out of a resumed run', async () => {
|
||||||
|
const id = seedRun({ status: 'running' })
|
||||||
|
Object.assign(runRow(id), { claimed_by: 'host:1', claim_expires_at: new Date(Date.now() + 900_000) })
|
||||||
|
|
||||||
|
await controls.pause(id, {}, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(runRow(id).claimed_by, null)
|
||||||
|
assert.equal(runRow(id).claim_expires_at, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a scheduled run cannot be paused — it is cancelled instead', async () => {
|
||||||
|
// Pausing one would leave a run that is neither going to start nor visibly
|
||||||
|
// abandoned, and resuming it after its grace window had passed would produce a
|
||||||
|
// `missed` from a button labelled resume.
|
||||||
|
const id = seedRun({ status: 'scheduled' })
|
||||||
|
const result = await controls.pause(id, {}, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 409)
|
||||||
|
assert.match(result.errors[0], /scheduled/)
|
||||||
|
assert.equal(runRow(id).status, 'scheduled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a completed run cannot be paused', async () => {
|
||||||
|
const id = seedRun({ status: 'completed' })
|
||||||
|
assert.equal((await controls.pause(id, {}, ACTOR)).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resume returns a run to running, or to starting when it never entered a phase', async () => {
|
||||||
|
const withPhase = seedRun({ status: 'paused', phase: 'main' })
|
||||||
|
assert.equal((await controls.resume(withPhase, {}, ACTOR)).ok, true)
|
||||||
|
assert.equal(runRow(withPhase).status, 'running')
|
||||||
|
|
||||||
|
const beforePhase = seedRun({ status: 'paused', phase: null })
|
||||||
|
assert.equal((await controls.resume(beforePhase, {}, ACTOR)).ok, true)
|
||||||
|
assert.equal(runRow(beforePhase).status, 'starting', 'both are in findDue; neither is a fourth column')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resume clears the error it was paused over and leaves health alone', async () => {
|
||||||
|
const id = seedRun({ status: 'paused' })
|
||||||
|
Object.assign(runRow(id), { last_error: 'core.spawn failed', health: 'degraded' })
|
||||||
|
|
||||||
|
await controls.resume(id, {}, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(runRow(id).last_error, null, 'a resolved failure must not accuse a healthy run for ever')
|
||||||
|
assert.equal(runRow(id).health, 'degraded', 'that this run has already had trouble stays true')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resume refuses a run that is not paused', async () => {
|
||||||
|
const id = seedRun({ status: 'running' })
|
||||||
|
const result = await controls.resume(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 409)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── cancel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('cancel closes out the pending steps and the parked cue, and leaves a leased step alone', async () => {
|
||||||
|
const id = seedRun({
|
||||||
|
status: 'running',
|
||||||
|
steps: [
|
||||||
|
{ status: 'done' },
|
||||||
|
{ status: 'running', leased: true }, // mid-dispatch: nothing can recall a sent command
|
||||||
|
{ status: 'running' }, // parked on a human: nothing is holding it
|
||||||
|
{ status: 'pending' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await controls.cancel(id, { reason: 'called off' }, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(runRow(id).status, 'cancelled')
|
||||||
|
assert.equal(result.cancelledSteps, 2)
|
||||||
|
const [done, leased, parked, pending] = stepsOf(id)
|
||||||
|
assert.equal(done.status, 'done')
|
||||||
|
assert.equal(leased.status, 'running', 'a step being dispatched is not touched')
|
||||||
|
assert.equal(parked.status, 'cancelled', 'a cancelled run must stop claiming to wait on somebody')
|
||||||
|
assert.equal(pending.status, 'cancelled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel is legal before a run has started', async () => {
|
||||||
|
const id = seedRun({ status: 'scheduled', steps: [{ status: 'pending' }] })
|
||||||
|
assert.equal((await controls.cancel(id, {}, ACTOR)).ok, true)
|
||||||
|
assert.equal(runRow(id).status, 'cancelled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel refuses a run that is already terminal', async () => {
|
||||||
|
for (const status of TERMINAL) {
|
||||||
|
const id = seedRun({ status })
|
||||||
|
const result = await controls.cancel(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false, `${status} should not be cancellable`)
|
||||||
|
assert.match(result.errors[0], new RegExp(status))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── confirm ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('confirm resolves a parked cue as done, keeping what the person says they did', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'running', actionId: 'core.cue' }] })
|
||||||
|
const [cue] = stepsOf(id)
|
||||||
|
|
||||||
|
const result = await controls.confirmStep(id, cue.id, { note: 'gate opened, herald read' }, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(stepsOf(id)[0].status, 'done', 'a person saying they did it is the step having succeeded')
|
||||||
|
assert.equal(stepsOf(id)[0].last_error, 'gate opened, herald read')
|
||||||
|
assert.equal(lastLog().detail.control, 'confirm')
|
||||||
|
assert.equal(lastLog().detail.by, ACTOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirm cannot resolve a step a process is dispatching', async () => {
|
||||||
|
// The whole vocabulary here is "running with a NULL lease". A live lease means
|
||||||
|
// something is mid-dispatch, and confirming it would race the process that
|
||||||
|
// owns the row.
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'running', leased: true }] })
|
||||||
|
const [busy] = stepsOf(id)
|
||||||
|
|
||||||
|
const result = await controls.confirmStep(id, busy.id, {}, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 409)
|
||||||
|
assert.equal(stepsOf(id)[0].status, 'running')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a step id from another run is a 404, not an action', async () => {
|
||||||
|
const mine = seedRun({ status: 'running', steps: [{ status: 'pending' }] })
|
||||||
|
const theirs = seedRun({ status: 'running', steps: [{ status: 'running' }] })
|
||||||
|
const [theirStep] = stepsOf(theirs)
|
||||||
|
|
||||||
|
const result = await controls.confirmStep(mine, theirStep.id, {}, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 404)
|
||||||
|
assert.equal(stepsOf(theirs)[0].status, 'running')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── skip ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('skip takes a pending step and a parked cue, and nothing else', async () => {
|
||||||
|
const id = seedRun({
|
||||||
|
status: 'running',
|
||||||
|
steps: [{ status: 'pending' }, { status: 'running' }, { status: 'running', leased: true }, { status: 'failed' }],
|
||||||
|
})
|
||||||
|
const [pending, parked, leased, failed] = stepsOf(id)
|
||||||
|
|
||||||
|
assert.equal((await controls.skipStep(id, pending.id, {}, ACTOR)).ok, true)
|
||||||
|
assert.equal((await controls.skipStep(id, parked.id, {}, ACTOR)).ok, true)
|
||||||
|
assert.equal((await controls.skipStep(id, leased.id, {}, ACTOR)).ok, false)
|
||||||
|
// A failed step does not need skipping: `nextOpenStep` already passes over it,
|
||||||
|
// so resuming the run carries the phase past it.
|
||||||
|
assert.equal((await controls.skipStep(id, failed.id, {}, ACTOR)).ok, false)
|
||||||
|
|
||||||
|
const after = stepsOf(id)
|
||||||
|
assert.equal(after[0].status, 'skipped')
|
||||||
|
assert.equal(after[1].status, 'skipped')
|
||||||
|
assert.equal(after[2].status, 'running')
|
||||||
|
assert.equal(after[3].status, 'failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skip refuses once the run is over', async () => {
|
||||||
|
const id = seedRun({ status: 'completed', steps: [{ status: 'pending' }] })
|
||||||
|
const [step] = stepsOf(id)
|
||||||
|
assert.equal((await controls.skipStep(id, step.id, {}, ACTOR)).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── retry ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('retry re-queues the step a paused run is stopped at, and resumes in the same action', async () => {
|
||||||
|
const id = seedRun({
|
||||||
|
status: 'paused',
|
||||||
|
steps: [{ status: 'done' }, { status: 'failed', attempts: 3 }, { status: 'pending' }],
|
||||||
|
})
|
||||||
|
const failed = stepsOf(id)[1]
|
||||||
|
|
||||||
|
const result = await controls.retryStep(id, failed.id, {}, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.resumed, true)
|
||||||
|
assert.equal(stepsOf(id)[1].status, 'pending')
|
||||||
|
assert.equal(stepsOf(id)[1].attempts, 0, 'the ceiling bounds the runner, not a person deciding once')
|
||||||
|
assert.equal(runRow(id).status, 'running', 'there is no state in which you would want half of this')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry refuses a step the run has already moved past', async () => {
|
||||||
|
// The case the guard exists for: a failed step under an `on_failure` of `skip`
|
||||||
|
// is one the phase carried on from. Re-queueing it would put a pending row
|
||||||
|
// behind the runner's cursor, where it would sit for ever.
|
||||||
|
const id = seedRun({
|
||||||
|
status: 'paused',
|
||||||
|
steps: [{ status: 'failed', attempts: 3 }, { status: 'done' }, { status: 'failed', attempts: 3 }],
|
||||||
|
})
|
||||||
|
const [movedPast] = stepsOf(id)
|
||||||
|
|
||||||
|
const result = await controls.retryStep(id, movedPast.id, {}, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 409)
|
||||||
|
assert.match(result.errors[0], /stopped at this step/)
|
||||||
|
assert.equal(stepsOf(id)[0].status, 'failed')
|
||||||
|
assert.equal(runRow(id).status, 'paused', 'a refused retry does not resume the run either')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry refuses a step in a phase the run has left', async () => {
|
||||||
|
const id = seedRun({
|
||||||
|
status: 'paused',
|
||||||
|
phase: 'two',
|
||||||
|
steps: [{ phase: 'one', seq: 0, status: 'failed' }, { phase: 'two', seq: 0, status: 'pending' }],
|
||||||
|
})
|
||||||
|
const [old] = stepsOf(id)
|
||||||
|
|
||||||
|
const result = await controls.retryStep(id, old.id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.errors[0], /already left/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry refuses while the run is still running', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'failed' }] })
|
||||||
|
const [failed] = stepsOf(id)
|
||||||
|
|
||||||
|
const result = await controls.retryStep(id, failed.id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.errors[0], /paused/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry refuses a step that is not failed', async () => {
|
||||||
|
const id = seedRun({ status: 'paused', steps: [{ status: 'pending' }] })
|
||||||
|
const [pending] = stepsOf(id)
|
||||||
|
assert.equal((await controls.retryStep(id, pending.id, {}, ACTOR)).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the record ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('every control writes one log line carrying the actor and the control name', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'running' }, { status: 'pending' }] })
|
||||||
|
const [parked, pending] = stepsOf(id)
|
||||||
|
|
||||||
|
await controls.confirmStep(id, parked.id, { note: 'done' }, ACTOR)
|
||||||
|
await controls.skipStep(id, pending.id, { reason: 'not needed' }, ACTOR)
|
||||||
|
await controls.pause(id, {}, ACTOR)
|
||||||
|
await controls.resume(id, {}, ACTOR)
|
||||||
|
await controls.cancel(id, { reason: 'over' }, ACTOR)
|
||||||
|
|
||||||
|
const human = store.log.filter((l) => l.detail?.control)
|
||||||
|
assert.deepEqual(human.map((l) => l.detail.control), ['confirm', 'skip', 'pause', 'resume', 'cancel'])
|
||||||
|
assert.ok(human.every((l) => l.detail.by === ACTOR))
|
||||||
|
// The kinds are the ones a reader already scans for. A human transition is
|
||||||
|
// still a transition; `detail.control` is what separates it from the runner's.
|
||||||
|
assert.deepEqual([...new Set(human.map((l) => l.kind))].sort(), ['run.status', 'step.status'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an empty reason is stored as NULL rather than as an empty string', async () => {
|
||||||
|
const id = seedRun({ status: 'running' })
|
||||||
|
await controls.pause(id, { reason: ' ' }, ACTOR)
|
||||||
|
assert.equal(lastLog().detail.reason, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── cancel decides what happens to the world (Phase 8) ─────────────────────
|
||||||
|
|
||||||
|
/** The `run.status` line for one run — Phase 10 stopped it being the last one. */
|
||||||
|
const statusLine = (id) => store.log.filter((l) => l.runId === id && l.kind === 'run.status').at(-1)
|
||||||
|
|
||||||
|
test('a refused cancel announces nothing at all', async () => {
|
||||||
|
// The emit is after the guarded transition, so the loser of a race between two
|
||||||
|
// moderators pressing cancel has already returned a 409 and said nothing.
|
||||||
|
const id = seedRun({ status: 'completed', steps: [] })
|
||||||
|
const refused = await controls.cancel(id, { reason: 'too late' }, ACTOR)
|
||||||
|
assert.equal(refused.ok, false)
|
||||||
|
assert.deepEqual(store.emits, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel gives back what the run took, by default and without waiting for it', async () => {
|
||||||
|
// The teardown is the runner cleanup leg over TERMINAL runs, not this request.
|
||||||
|
// Two reasons, and both are why the control answers at once: a cancel pressed
|
||||||
|
// at two in the morning must not block on a dozen round trips to the shard
|
||||||
|
// that may BE the reason it is being cancelled, and a process that dies
|
||||||
|
// halfway through a teardown has to resume rather than leave a world half
|
||||||
|
// restored with nothing scheduled to finish it.
|
||||||
|
const id = seedRun({ status: 'running', cleanupStatus: 'pending', steps: [{ status: 'pending' }] })
|
||||||
|
store.unresolved[id] = 3
|
||||||
|
|
||||||
|
const result = await controls.cancel(id, { reason: 'called off' }, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.cleanup, true)
|
||||||
|
assert.deepEqual(store.sweeps, [], 'the request must not do the teardown itself')
|
||||||
|
// Still `pending`, which is what the leg looks for. The run is terminal the
|
||||||
|
// moment this returns, so the very next tick picks its ledger up.
|
||||||
|
assert.equal(runRow(id).cleanup_status, 'pending')
|
||||||
|
// The status line, found by kind rather than by being last: Phase 10 put an
|
||||||
|
// `announcement.emitted` line after it, because the announcement genuinely
|
||||||
|
// happens after the guarded transition.
|
||||||
|
assert.equal(statusLine(id).detail.cleanup, true)
|
||||||
|
|
||||||
|
// …and the run announced its own cancellation, with the operator's reason and
|
||||||
|
// not the diagnostic string that ends up in `last_error`.
|
||||||
|
assert.deepEqual(store.emits.map((e) => e.triggerId), ['event.run.cancelled'])
|
||||||
|
assert.equal(store.emits[0].envelope.data.reason, 'called off')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel WITHOUT cleanup is admin-only, even though the route is wider', async () => {
|
||||||
|
// §L: "cancelling without cleanup is a separate, logged, admin-only action."
|
||||||
|
// The route 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).
|
||||||
|
const id = seedRun({ status: 'running', cleanupStatus: 'pending', steps: [{ status: 'pending' }] })
|
||||||
|
store.unresolved[id] = 3
|
||||||
|
|
||||||
|
const refused = await controls.cancel(id, { cleanup: false }, ACTOR, { isAdmin: false })
|
||||||
|
assert.equal(refused.ok, false)
|
||||||
|
assert.equal(refused.status, 403)
|
||||||
|
assert.equal(runRow(id).status, 'running', 'and the run is not cancelled either')
|
||||||
|
|
||||||
|
// A moderator asking for the ordinary cancel is fine: the safe direction is
|
||||||
|
// the default, so the widest gate keeps the button it exists for.
|
||||||
|
const allowed = await controls.cancel(id, {}, ACTOR, { isAdmin: false })
|
||||||
|
assert.equal(allowed.ok, true)
|
||||||
|
assert.equal(allowed.cleanup, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel without cleanup leaves the world changes up, and says so on the run', async () => {
|
||||||
|
// `incomplete` is the truthful value rather than a tidy one: the changes are
|
||||||
|
// still up, they are listed on the console, and the log line records who
|
||||||
|
// decided that. A `complete` here would be the "tidy completed row over a shard
|
||||||
|
// full of orphaned monsters" §L names as the failure that ends this feature's
|
||||||
|
// credibility.
|
||||||
|
const id = seedRun({ status: 'running', cleanupStatus: 'pending', steps: [{ status: 'pending' }] })
|
||||||
|
store.unresolved[id] = 3
|
||||||
|
|
||||||
|
const result = await controls.cancel(id, { cleanup: false, reason: 'leave it up' }, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.cleanup, false)
|
||||||
|
assert.equal(runRow(id).cleanup_status, 'incomplete')
|
||||||
|
assert.equal(statusLine(id).detail.cleanup, false)
|
||||||
|
assert.equal(statusLine(id).detail.by, ACTOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run that recorded nothing is unaffected by either flag', async () => {
|
||||||
|
// `not_required` is not walked to `incomplete` by a cancel that skipped a
|
||||||
|
// teardown there was nothing to do — and it is the LEDGER that says so, not the
|
||||||
|
// status column, because `not_required` is also what a run holding only a lease
|
||||||
|
// wrongly carried before the live walk found it.
|
||||||
|
const id = seedRun({ status: 'running', cleanupStatus: 'not_required', steps: [{ status: 'pending' }] })
|
||||||
|
store.unresolved[id] = 0
|
||||||
|
await controls.cancel(id, { cleanup: false }, ACTOR)
|
||||||
|
assert.equal(runRow(id).cleanup_status, 'not_required')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── cleanup, the eighth control ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('cleanup re-runs the teardown and clears the attempt counter', async () => {
|
||||||
|
// The manual retry §L promises. `resetAttempts` is the licence a human has and
|
||||||
|
// the automatic sweep does not — Engagement Phase 14's rule, whose defect was
|
||||||
|
// a sweep that reset every stale row and made the attempt ceiling unreachable.
|
||||||
|
const id = seedRun({ status: 'completed', cleanupStatus: 'incomplete' })
|
||||||
|
|
||||||
|
const result = await controls.cleanupRun(id, ACTOR)
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.deepEqual(store.sweeps, [{ runId: id, resetAttempts: true, actor: ACTOR }])
|
||||||
|
assert.equal(result.summary.reverted, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cleanup refuses a run that is still in flight', async () => {
|
||||||
|
// A run still going 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. Cancel is the control for a run that should stop.
|
||||||
|
for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
|
||||||
|
const id = seedRun({ status, cleanupStatus: 'pending' })
|
||||||
|
const result = await controls.cleanupRun(id, ACTOR)
|
||||||
|
assert.equal(result.ok, false, status)
|
||||||
|
assert.match(result.errors[0], /cancel it before cleaning up after it/)
|
||||||
|
}
|
||||||
|
assert.deepEqual(store.sweeps, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cleanup refuses a run that recorded no resources', async () => {
|
||||||
|
const id = seedRun({ status: 'completed', cleanupStatus: 'not_required' })
|
||||||
|
const result = await controls.cleanupRun(id, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.errors[0], /nothing to give back/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cleanup on an unknown run is a 404, not a 409', async () => {
|
||||||
|
const result = await controls.cleanupRun(9999, ACTOR)
|
||||||
|
assert.equal(result.status, 404)
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user