The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.
Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it is
one capability over thousands of spawners, and a reservation on the id alone
would let one run turning up one spawner refuse every other run every other
spawner. So a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving it coming from
the same unique index it always did.
Extending core rather than giving the module a lease verb of its own is what §F
decided in Phase 8 ("the verb is core's"): a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory
everywhere and wrong in the first one that forgot. Half that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.
Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.
`values` closes a `string` lease's set. `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 arriving unattended, mid-run, from a step nobody is
watching. Refused on any other type: a set beside `min`/`max` would be a second
bound with no rule about which wins.
Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the failure 12a named for decoration, arriving
for real. `resolve({ q })` is additive: every source is passed a term, none is
required to read one, and a `searchable` flag says which do, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.
`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.
The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.
2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.
Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
483 lines
55 KiB
JavaScript
483 lines
55 KiB
JavaScript
// 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 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
|