// ── 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 }) }