From 8e03497eb320f6c74ba8c7c3bb658e44d0fe899d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 23:29:07 -0500 Subject: [PATCH 01/18] feat(events): schema, CRUD and the core action registry (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not depend on the module contract — plus definitions CRUD, publish, archive, and the action registry with core as its first registrant. **Nothing dispatches.** There is no runner until Phase 2, so a run row is created and stays `scheduled`. That is this phase's correct answer and the surface renders it verbatim rather than hiding it. Schema (`db/schema.sql`, append-only): event_series, event_definitions, event_versions, event_runs, event_run_steps, event_run_log. The four that need a writer — event_action_settings, event_run_budget, event_run_resources, event_run_participants — arrive with the phases that give them one. Registry (`modules/registries.js` + `config/coreEventActions.js`): registerEventActions staging and commit, with its own id namespace, the closed risk and reversibility sets, revert() required iff and only iff reversible: 'ledger', a bounded budgetMs and a param shape whose every entry needs a type and an example. perform/revert/cost are stripped from everything the catalog serves. Core declares core.announce, core.wait and core.cue through the same staging area a module will use. It is reachable ONLY by registerCore(): loader.js builds its own api facade and has no method that delegates here, so no module can call it and MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump. Surface (13 routes under /api/v1/admin/events): Reads staff-wide; publish, archive and run creation admin-only from this phase per EVENTS.md §N2, even though the switchboard they will consult does not exist yet — a button that is admin-only later and open now is a gate nobody notices was missing. The live run controls and `verify` are absent rather than stubbed, because nothing is in flight yet. Four things the build settled, all recorded in docs: - event_definitions gained a `spec` column. A draft's working copy cannot be an event_versions row: that table is immutable and a run pins one. - The spec validator must accept its own output. It added `actionVersion` and `dormant` and then refused them as unknown keys, which would have made the second save of any definition — and publish's re-validation — impossible. A test caught it; both are now accepted and recomputed. - A param's `example` is required, optional params included, matching registerEventTriggers. It is the authoring form's placeholder. - Two routes the §API-surface table did not name: GET /admin/events/:id and GET /admin/events/series. Core's three perform() bodies answer { ok: false, retry: false } rather than { ok: true }: `ok: true` on an action that did nothing is a recorded world change that did not occur, which is the exact mistake §F's failure default exists to prevent. `conditions.checkLiteral` is exported and reused for step-param type checking — one switch over the six types, so "is this a datetime" has one answer. Verified: 44 new tests, whole server suite, `npm run check:modules`, routes manifest and swagger regenerated (the manifest diff is +13 routes, zero moved). Docs: RunicGateway/docs#209 Co-Authored-By: Claude --- server/db/schema.sql | 266 ++++ server/routes.guards.json | 117 ++ server/routes.manifest.json | 52 + server/src/config/coreEventActions.js | 153 +++ server/src/engagement/conditions.js | 16 +- server/src/events/spec.js | 317 +++++ .../src/model/events/eventDefinitions.db.js | 134 ++ .../model/events/eventDefinitions.model.js | 270 ++++ server/src/model/events/eventJson.js | 23 + server/src/model/events/eventRunLog.db.js | 67 + server/src/model/events/eventRunSteps.db.js | 95 ++ server/src/model/events/eventRuns.db.js | 105 ++ server/src/model/events/eventRuns.model.js | 134 ++ server/src/model/events/eventSeries.db.js | 23 + server/src/model/events/eventVersions.db.js | 54 + server/src/modules/registries.js | 254 ++++ .../src/router/v1/admin/events.controller.js | 315 +++++ server/src/router/v1/admin/events.router.js | 201 +++ server/src/router/v1/admin/index.js | 8 + server/swagger/swagger-output.json | 1087 +++++++++++++++++ server/test/eventActionRegistry.test.js | 196 +++ server/test/eventSpec.test.js | 231 ++++ server/test/eventsAdmin.test.js | 597 +++++++++ 23 files changed, 4714 insertions(+), 1 deletion(-) create mode 100644 server/src/config/coreEventActions.js create mode 100644 server/src/events/spec.js create mode 100644 server/src/model/events/eventDefinitions.db.js create mode 100644 server/src/model/events/eventDefinitions.model.js create mode 100644 server/src/model/events/eventJson.js create mode 100644 server/src/model/events/eventRunLog.db.js create mode 100644 server/src/model/events/eventRunSteps.db.js create mode 100644 server/src/model/events/eventRuns.db.js create mode 100644 server/src/model/events/eventRuns.model.js create mode 100644 server/src/model/events/eventSeries.db.js create mode 100644 server/src/model/events/eventVersions.db.js create mode 100644 server/src/router/v1/admin/events.controller.js create mode 100644 server/src/router/v1/admin/events.router.js create mode 100644 server/test/eventActionRegistry.test.js create mode 100644 server/test/eventSpec.test.js create mode 100644 server/test/eventsAdmin.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index f7fa01b..b1e3bea 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -2083,3 +2083,269 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions ( INDEX idx_engsup_created (created_at), INDEX idx_engsup_reason (reason, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ── The Event System (EVENTS.md §D — Phase 1) ────────────────────────────── + +-- Six of the nine core tables land here: the ones that do not depend on the +-- module contract. `event_action_settings`, `event_run_budget`, +-- `event_run_resources` and `event_run_participants` arrive with the phases that +-- give them a writer (P6, P8, P10) rather than as empty tables nothing reads. +-- +-- Core tables, so no module prefix, and no game vocabulary anywhere below: an +-- action id, a scope, a resource kind and a budget dimension are all opaque +-- strings core stores and never interprets (§C). + +-- The arc. Definitions optionally belong to one, and the series is what carries +-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From +-- the Void" is a thing the tooling this replaces cannot express at all. +-- +-- The table lands in Phase 1 because `event_definitions.series_id` points at it; +-- the routes that create and order one are Phase 4's, where the calendar makes +-- an arc visible. +CREATE TABLE IF NOT EXISTS event_series ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(160) NOT NULL, + slug VARCHAR(160) NOT NULL, + description TEXT NULL, + -- Where this series sits among the others on the calendar. Not a position + -- WITHIN the series: a definition's place in its arc is `event_definitions`' + -- own `series_order` below, because that is the column an editor drags. + ordering INT NOT NULL DEFAULT 0, + created_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_evser_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The thing that is listed, searched, scheduled and audited. +-- +-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an +-- admin publishes their own work, so there is nobody to submit it to, and a +-- review state nobody uses is a state every query has to remember anyway. +-- +-- `owner_module` is NULLable and is the module that SHIPPED this definition as +-- content, not the module whose actions its steps call — a definition may call +-- three modules' verbs and belong to none of them. NULL means an operator +-- authored it here, which is the ordinary case. +-- +-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value +-- (`invasion:{region}`), because it is rendered from a run's own params at +-- materialisation (§E). A flat definition-id key would wrongly stop one +-- definition running in two regions at once. +-- +-- `current_version_id` carries NO foreign key, deliberately, and it is the one +-- column in this group without one: `event_versions.definition_id` already +-- points back here, and a second FK in the other direction makes the pair a +-- chicken and an egg on insert. +CREATE TABLE IF NOT EXISTS event_definitions ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL, + summary VARCHAR(500) NULL, + -- The storyline. Sanitized HTML, the same treatment a wiki page gets. + body MEDIUMTEXT NULL, + image_url VARCHAR(500) NULL, + owner_module VARCHAR(64) NULL, + state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft', + current_version_id INT NULL, + -- The WORKING COPY of the spec - phases and their steps - as the author last + -- saved it. §D's column list does not name it because §D describes what a + -- PUBLISHED event is made of, and a version row is where a spec ends up. But + -- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning") + -- has to mean the draft lives somewhere, and it cannot be an `event_versions` + -- row: that table is immutable and a run pins one, so a mutable unpublished + -- row in it would be the exact thing versioning exists to prevent. Publishing + -- copies this column into a version and leaves it here as the next draft. + spec JSON NOT NULL, + series_id INT NULL, + series_order INT NOT NULL DEFAULT 0, + concurrency_key VARCHAR(190) NULL, + -- The grace window (§E). A schedule that passed this many seconds ago while the + -- process was down is `missed`, never a late silent start. + grace_seconds INT NOT NULL DEFAULT 900, + -- IANA, and it belongs to the EVENT rather than to the viewer: every listing + -- this replaces is written in the shard's local zone, and a recurrence computed + -- in UTC puts a Friday-8pm event at 7pm for half the year. + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + created_by INT NULL, + updated_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL, + CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_evdef_slug (slug), + -- The admin list's default ordering, and the public calendar's filter. + INDEX idx_evdef_state (state, updated_at), + INDEX idx_evdef_series (series_id, series_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- An immutable snapshot of a whole definition spec: phases, steps, schedule, +-- conditions, announcements. A run pins one, and that pin is the entire reason +-- this table exists — it is what makes a run reproducible, and an audit +-- answerable, after the definition has been edited underneath it. +-- +-- Nothing updates a row here. Editing a `ready` definition creates the NEXT +-- version on publish; a live run keeps the version it pinned and is unaffected +-- (EVENTS.md "Versioning, and editing a live event"). +CREATE TABLE IF NOT EXISTS event_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + definition_id INT NOT NULL, + version INT NOT NULL, + spec JSON NOT NULL, + published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_by INT NULL, + CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE, + CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL, + -- Two publishes racing for version 4 is one 1062, not two rows called 4. + UNIQUE KEY uq_evver_def_version (definition_id, version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- One occurrence of one definition, in one scope. +-- +-- The UNIQUE key below — not the claim — is what makes "one run per occurrence +-- per scope" true (§E). The claim decides WHO advances an occurrence; this index +-- is what stops two of them existing. `scope` is inside the key so a worldwide +-- event fans out to many servers without colliding with itself, and it is +-- module-opaque: core stores the string and never parses it. +-- +-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was +-- COMPUTED in (Phase 4); what is stored is the instant. +-- +-- `health` is a separate column from `status` because a run can be genuinely +-- running and degraded at once — announcements landing, world writes parked — +-- and one column cannot say both. `cleanup_status` is separate for the mirror +-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather +-- than being held open, and stays on the admin screen until a human resolves it. +-- +-- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run +-- whose pinned spec had been deleted could not be explained afterwards, which is +-- the one thing this table is for. +CREATE TABLE IF NOT EXISTS event_runs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + definition_id INT NOT NULL, + version_id INT NOT NULL, + -- Module-opaque, and '' rather than NULL for the single-scope case: it is part + -- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL + -- scope would silently permit two runs of one occurrence. + scope VARCHAR(190) NOT NULL DEFAULT '', + status ENUM('scheduled','starting','running','paused','ending', + 'completed','cancelled','failed','missed') + NOT NULL DEFAULT 'scheduled', + health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok', + cleanup_status ENUM('not_required','pending','complete','incomplete') + NOT NULL DEFAULT 'not_required', + current_phase VARCHAR(64) NULL, + scheduled_for DATETIME NOT NULL, + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + concurrency_key VARCHAR(190) NULL, -- rendered from this run's params + params JSON NULL, + -- A rehearsal dispatches for real but is excluded from the public calendar and + -- from participation history. Declared here in Phase 1 so the column exists + -- before anything can create a run without it. + rehearsal TINYINT(1) NOT NULL DEFAULT 0, + started_at DATETIME NULL, + ended_at DATETIME NULL, + claimed_by VARCHAR(64) NULL, + claim_expires_at DATETIME NULL, + started_by INT NULL, + last_error VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE, + CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id), + CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for), + -- The runner's materialise/advance scan: due runs by status. + INDEX idx_evrun_due (status, scheduled_for), + -- The admin run list, newest first, and the per-definition history. + INDEX idx_evrun_def (definition_id, scheduled_for), + -- Phase 2's overlap check. NULL keys are skipped by the index, which is right: + -- a definition with no key never contends. + INDEX idx_evrun_concurrency (concurrency_key, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The work queue: one action invocation, claimed with the outbox's +-- compare-and-set. This is the shape `engagement_outbox` already proved, and +-- retries, timeouts, duplicate execution and resumption are all properties of +-- this row rather than of a scheduler's memory. +-- +-- `idempotency_key` is minted ONCE at materialisation and does not vary by +-- attempt (§E) — a retry re-sends the same key so the game side can recognise +-- the repeat. It is generated by core rather than by the module because core is +-- what guarantees its stability. +-- +-- `action_version` records what the step was AUTHORED against. A module that +-- bumps its action makes the step render a warning in the editor rather than +-- dispatch a mistyped parameter. +-- +-- `refused` is in the status set and is deliberately not `failed`: a cap breach +-- means nothing is wrong with the system, and an author asked for more than this +-- deployment allows. +CREATE TABLE IF NOT EXISTS event_run_steps ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + phase VARCHAR(64) NOT NULL, + seq INT NOT NULL, + action_id VARCHAR(96) NOT NULL, + params JSON NULL, + action_version INT NOT NULL DEFAULT 1, + status ENUM('pending','running','done','failed','skipped','refused','cancelled') + NOT NULL DEFAULT 'pending', + due_at DATETIME NULL, + attempts INT NOT NULL DEFAULT 0, + -- Defaulted from the action's risk class at materialisation (§L): retry->skip + -- for notify, retry->pause for change, retry->abort_run for irreversible. + on_failure VARCHAR(32) NOT NULL DEFAULT 'skip', + idempotency_key CHAR(40) NOT NULL, + claimed_by VARCHAR(64) NULL, + claim_expires_at DATETIME NULL, + last_error VARCHAR(500) NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + -- The drain scan, exactly as written: due, pending, oldest first. + INDEX idx_evstep_due (status, due_at), + -- The run console: every step of one run in authored order. + INDEX idx_evstep_run (run_id, phase, seq), + -- Materialisation is INSERT IGNORE against this, so a tick that overruns into + -- the next one cannot double-materialise a phase. + UNIQUE KEY uq_evstep_slot (run_id, phase, seq) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- "Why didn't phase 3 start?" must be a query. +-- +-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this +-- table exists rather than the audit log being reused: an operator diagnosing a +-- stalled phase needs to filter by kind and read structured detail, and an +-- administrative audit of WHO published WHAT is a different question with a +-- different retention. Both are written — the audit to `activity_log`, the +-- diagnosis here. +-- +-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM, +-- because the set grows with almost every later phase and an ENUM change is a +-- table alter this project has no migration system for. +-- +-- The log is high-cardinality and grows per event, so it needs a retention sweep +-- from the start — `engagementRetentionPrune` is the pattern, and the rule that +-- work learned is that only TERMINAL rows are eligible. The sweep itself lands +-- with the runner in Phase 2; the index it needs is here from the beginning. +CREATE TABLE IF NOT EXISTS event_run_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + step_id BIGINT NULL, + kind VARCHAR(48) NOT NULL, + phase VARCHAR(64) NULL, + detail JSON NULL, + at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, + CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL, + -- The run console reads this whole index and nothing else. + INDEX idx_evlog_run (run_id, at), + -- What the Phase 2 retention sweep queries. Without it the sweep is a table + -- scan of every line this deployment has ever logged. + INDEX idx_evlog_at (at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/routes.guards.json b/server/routes.guards.json index 56551b2..c53a92f 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -419,6 +419,123 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/events", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/events", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/events/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/:id", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/publish", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/runs", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id/versions", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/catalog", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId/log", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/events/series", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/invites", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 4dce8bf..1adec3c 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -185,6 +185,58 @@ "method": "GET", "path": "/api/v1/admin/engagement/triggers" }, + { + "method": "GET", + "path": "/api/v1/admin/events" + }, + { + "method": "POST", + "path": "/api/v1/admin/events" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/events/:id" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id" + }, + { + "method": "PUT", + "path": "/api/v1/admin/events/:id" + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/publish" + }, + { + "method": "POST", + "path": "/api/v1/admin/events/:id/runs" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/:id/versions" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/catalog" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/runs/:runId/log" + }, + { + "method": "GET", + "path": "/api/v1/admin/events/series" + }, { "method": "GET", "path": "/api/v1/admin/invites" diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js new file mode 100644 index 0000000..d26eb21 --- /dev/null +++ b/server/src/config/coreEventActions.js @@ -0,0 +1,153 @@ +// ── Core's own event actions ─────────────────────────────────────────────── +// +// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js +// and registered through the same staging area a module will use in Phase 7 — +// which is the entire reason these three exist this early. A registry whose first +// real registrant is a module is a registry that has already drifted, and §F's +// claim that core is "an event engine that can announce, wait, cue a human and +// publish results" with NO module installed is only true if core declares the +// verbs that do it. +// +// **Three actions, and between them they cover the three things an event can do +// that name no game noun at all**: tell people something, let time pass, and ask +// a human to go and do something. A deployment with no game module installed has +// a working event system made of exactly these. +// +// **Nothing here dispatches yet.** Phase 1 builds the registry, the id grammar, +// the risk classes and the param validation; Phase 2 builds `utils/eventRunner.js` +// and is what calls `perform()`. The bodies below therefore answer with the +// envelope §F defines for a refusal — and specifically NOT with `{ ok: true }`, +// which is the one wrong answer a placeholder can give: `ok: true` on an action +// that did nothing is a recorded world change that did not occur, which is the +// exact mistake the envelope's failure default exists to prevent. `retry: false` +// because a missing runner is not a transient condition. +// +// **This file must not touch the database.** It is required from `registerCore()`, +// which runs under `routeManifest.js` and `swagger.js` against a dead pool +// (MODULE_API.md §2.2). It is pure data plus three functions that are not called. + +// A factory rather than one shared function, because `perform`'s argument is +// §F's dispatch envelope — `{ runId, stepId, idempotencyKey, scope, params, +// actor, verify }` — and it does not carry the action's own id. Closing over it +// is what lets the refusal name which action refused. +const notWiredYet = (actionId) => async () => ({ + ok: false, + retry: false, + error: `${actionId} is declared in Phase 1 and dispatched from Phase 2`, +}) + +const ACTIONS = [ + { + id: 'core.announce', + label: 'Announce', + description: + 'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.', + + // Nothing in the world changes and nothing is created: a message goes out. + // That is what makes the default `on_failure` for this step `retry -> skip` + // (§L) rather than `pause`, and it is the honest class even though the + // message itself cannot be unsent. + risk: 'notify', + // A sent announcement is gone. `none` rather than `ledger` is not an + // omission — there is no undo to write, and declaring `ledger` would put a + // row in the cleanup ledger that teardown could never resolve. + reversible: 'none', + version: 1, + + params: [ + { + // A leg id, checked against the announce-leg registry at dispatch rather + // than here: legs are registered by modules, and this file is evaluated + // before any module has registered anything. + name: 'leg', + type: 'string', + required: true, + example: 'discord', + description: 'The announce leg to publish on. Registered legs only.', + }, + { + name: 'title', + type: 'string', + required: false, + example: 'The gates of Britain open at dusk', + description: 'Optional heading, for legs that render one.', + }, + { + name: 'body', + type: 'string', + required: true, + example: 'A caravan has been sighted on the road east of Cove.', + description: 'The announcement itself. Plain text.', + }, + ], + + perform: notWiredYet('core.announce'), + }, + + { + id: 'core.wait', + label: 'Wait', + description: 'Let a fixed amount of time pass before the next step of this phase runs.', + + // `inspect` rather than `notify`: nothing is sent and nobody is told. It is + // the weakest class the closed set has for an action that is not a broadcast. + risk: 'inspect', + reversible: 'none', + version: 1, + + params: [ + { + name: 'seconds', + type: 'int', + required: true, + example: 300, + description: 'How long to wait. The runner sets the next step due_at from this.', + }, + ], + + // A wait is a genuine no-op at dispatch, and it will stay one: the delay is + // the NEXT step's `due_at`, which the runner owns, not something this + // function sleeps through. A `perform` that slept would hold a step's claim + // for the duration and turn a five-minute pause into a five-minute lease. + perform: notWiredYet('core.wait'), + }, + + { + id: 'core.cue', + label: 'Cue a human', + description: + 'Post an instruction for staff and wait for someone to confirm it was done before the run advances.', + + // The action itself only posts an instruction. Whatever the human then does + // is outside this system entirely, which is precisely why the cue exists: + // it is how an event uses a capability no module has automated. + risk: 'notify', + reversible: 'none', + version: 1, + + params: [ + { + name: 'instruction', + type: 'string', + required: true, + example: 'Open the north gate and read the herald script in Britain bank.', + description: 'What the staff member is being asked to do.', + }, + { + name: 'assignee', + type: 'string', + required: false, + example: 'Event Team', + description: 'Who the cue is addressed to. A label, not an account.', + }, + ], + + // Phase 2 gives this its parking semantics — a cue step does not complete + // when `perform` answers, it completes when a human presses confirm, and the + // control that does so is Phase 3's. Both of those are what make this the + // one action whose runtime shape is deliberately not decided here. + perform: notWiredYet('core.cue'), + }, +] + +module.exports = { ACTIONS } diff --git a/server/src/engagement/conditions.js b/server/src/engagement/conditions.js index 2f99031..e6e182c 100644 --- a/server/src/engagement/conditions.js +++ b/server/src/engagement/conditions.js @@ -248,4 +248,18 @@ const vocabulary = () => /** Convenience for a caller holding only a trigger id. */ const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw) -module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH } +// `checkLiteral` is exported for the event system's step-param validator +// (EVENTS.md §F, Phase 1), which checks an authored param value against an +// action's declared param type — the same six types over the same coercion. A +// second copy of this switch would be a second answer to "is this a datetime", +// and the two would drift on the first zone-suffixed string somebody typed. +module.exports = { + validate, + validateFor, + evaluate, + vocabulary, + checkLiteral, + OPERATORS, + MAX_LIST, + MAX_DEPTH, +} diff --git a/server/src/events/spec.js b/server/src/events/spec.js new file mode 100644 index 0000000..7551d96 --- /dev/null +++ b/server/src/events/spec.js @@ -0,0 +1,317 @@ +// ── The event spec, and the one place it is validated ────────────────────── +// +// EVENTS.md §C ("phases and actions are configuration inside a version snapshot, +// not tables") and §D. A spec is the authored tree a definition carries and a +// version freezes: phases, and the steps inside them. It is stored as JSON in +// `event_definitions`' working copy and in `event_versions.spec`, and this file +// is the only thing that decides whether one is well formed. +// +// **Every check here is a boundary, not a convenience.** The authoring UI (Phase +// 3, then Phase 13) will re-implement some of them for the sake of a good +// inline error, and that second copy is expected to drift — so this one is the +// one that decides. A spec arriving by any other route (a restore, a fixture, a +// module shipping a definition as content) gets the same answer. +// +// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys +// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind: +// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence +// in an IANA zone and a spec that could name `weekly` before then would be a +// schedule nothing honours. Unknown top-level keys are REFUSED rather than +// preserved: a spec that silently carries `announcements` today is a spec whose +// author believes announcements work, and the later phase that gives the key +// meaning would inherit a corpus of unvalidated ones. The refusal list is the +// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's +// `advance`, Phase 10 adds `announcements`. + +const registries = require('../modules/registries') +const { checkLiteral } = require('../engagement/conditions') + +// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the +// run console groups by, and it is what an operator reads in "phase 3 has not +// started". Same grammar as a template key's segment. +const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/ +const MAX_PHASE_KEY = 64 + +// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal +// with a number in it rather than a run that materialises fifty thousand step +// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side. +const MAX_PHASES = 40 +const MAX_STEPS_PER_PHASE = 100 +const MAX_STEPS = 500 + +// The schedule shapes this phase understands. Phase 4 replaces this list with +// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and +// their timezone arithmetic. It is a list of one rather than an implicit default +// so that the widening is a diff on this line. +const SCHEDULE_KINDS = ['manual'] + +// What a step does when its attempts are exhausted (§L). The disposition only — +// retry is not one of the values, it is what happens BEFORE one of them. Each +// risk class has a default, which is the whole reason `risk` is required at +// registration: a `change` action that fell back to `skip` would leave a run +// advancing over a half-changed world. +const ON_FAILURE = ['skip', 'pause', 'abort_run'] +const ON_FAILURE_BY_RISK = { + notify: 'skip', + inspect: 'skip', + change: 'pause', + irreversible: 'abort_run', +} + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +/** The default disposition for an action whose risk class core knows. */ +const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause' + +/** + * Check one authored param object against an action's declared params. + * + * Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent + * drop: an author who typed `creatures` where the action declares `creature` has + * written a step that would dispatch with the count missing, and dropping the key + * makes that look like it saved cleanly. + */ +function checkParams(declaration, raw, path) { + const errors = [] + const params = {} + const given = isPlainObject(raw) ? raw : {} + + if (raw !== undefined && raw !== null && !isPlainObject(raw)) { + return { params, errors: [`${path}.params: expected an object`] } + } + + const declared = new Map((declaration.params || []).map((p) => [p.name, p])) + for (const name of Object.keys(given)) { + if (!declared.has(name)) { + errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`) + } + } + + for (const p of declaration.params || []) { + const value = given[p.name] + if (value === undefined || value === null || value === '') { + if (p.required) errors.push(`${path}.params: "${p.name}" is required`) + continue + } + const checked = checkLiteral(p.type, value) + if (checked.error) { + errors.push(`${path}.params: "${p.name}" ${checked.error}`) + continue + } + params[p.name] = checked.value + } + + return { params, errors } +} + +/** + * Validate and normalise a whole spec. + * + * `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }` + * listing EVERY problem rather than the first — the posture `conditions.validate` + * takes, and for the same reason: an author fixing one step at a time is an + * author making six round trips through a form. + * + * `knownActionIds` widens what may be named beyond what is registered right now, + * and it is how a definition survives its module being uninstalled. The rule, + * lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger: + * **a step already in the saved spec may keep an unregistered action; a new step + * may not add one.** Refusing to save the whole definition would make an + * uninstall destructive after the fact, and silently dropping the step would + * delete authored work to make a form submit. A kept step is marked + * `dormant: true` and its params pass through unvalidated, because the only + * thing that could validate them left with the module. + */ +function validate(raw, { knownActionIds = [] } = {}) { + const errors = [] + const known = new Set(knownActionIds) + + if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] } + + const allowed = new Set(['schedule', 'phases']) + for (const key of Object.keys(raw)) { + if (!allowed.has(key)) { + errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`) + } + } + + // ── schedule ── + const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule + let schedule = { kind: 'manual' } + if (!isPlainObject(rawSchedule)) { + errors.push('spec.schedule: expected an object') + } else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) { + errors.push( + `spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`, + ) + } else { + const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind') + if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`) + schedule = { kind: rawSchedule.kind } + } + + // ── phases ── + const rawPhases = raw.phases + const phases = [] + if (!Array.isArray(rawPhases) || rawPhases.length === 0) { + errors.push('spec.phases: expected a non-empty array') + return { ok: false, errors } + } + if (rawPhases.length > MAX_PHASES) { + errors.push(`spec.phases: at most ${MAX_PHASES} phases`) + return { ok: false, errors } + } + + const seenKeys = new Set() + let totalSteps = 0 + + rawPhases.forEach((rawPhase, pi) => { + const path = `spec.phases[${pi}]` + if (!isPlainObject(rawPhase)) { + errors.push(`${path}: expected an object`) + return + } + const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps'].includes(k)) + if (extra.length) { + errors.push(`${path}: unknown key(s) ${extra.join(', ')} (a phase gains "advance" in Phase 5)`) + } + + const key = rawPhase.key + if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) { + errors.push(`${path}.key: bad phase key "${key}"`) + } else if (seenKeys.has(key)) { + // Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two + // phases sharing a key would silently collapse into one at materialisation + // and half the authored steps would never exist. + errors.push(`${path}.key: "${key}" is used by more than one phase`) + } else { + seenKeys.add(key) + } + + if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`) + + const rawSteps = rawPhase.steps + if (!Array.isArray(rawSteps)) { + errors.push(`${path}.steps: expected an array`) + return + } + if (rawSteps.length > MAX_STEPS_PER_PHASE) { + errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`) + return + } + totalSteps += rawSteps.length + + const steps = [] + rawSteps.forEach((rawStep, si) => { + const spath = `${path}.steps[${si}]` + if (!isPlainObject(rawStep)) { + errors.push(`${spath}: expected an object`) + return + } + // `actionVersion` and `dormant` are in this list because **validate must + // accept its own output**. A saved spec is re-validated on every later + // save and again at publish, so a normalised field that the validator + // itself added and then refused would make the second save of any + // definition impossible. They are accepted and then RECOMPUTED below + // rather than trusted: the version comes from the declaration, and + // dormancy from whether anyone registers the action right now. + const stepExtra = Object.keys(rawStep).filter( + (k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k), + ) + if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`) + + const actionId = rawStep.actionId + const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null + + if (typeof actionId !== 'string' || !actionId) { + errors.push(`${spath}.actionId: a step needs an action`) + return + } + if (!declaration && !known.has(actionId)) { + errors.push(`${spath}.actionId: no module registers "${actionId}"`) + return + } + + if (!declaration) { + // Dormant: kept verbatim, params untouched, and flagged so the editor and + // the run console can both say WHY rather than showing an empty step. + steps.push({ + actionId, + label: rawStep.label || actionId, + params: isPlainObject(rawStep.params) ? rawStep.params : {}, + actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1, + onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause', + dormant: true, + }) + return + } + + const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath) + errors.push(...paramErrors) + + if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) { + errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`) + } + + steps.push({ + actionId, + label: rawStep.label || declaration.label, + params, + // Captured at SAVE time, from the declaration this step was authored + // against (§F). It is what lets a later bump render a warning in the + // editor instead of dispatching a mistyped parameter. + actionVersion: declaration.version, + onFailure: ON_FAILURE.includes(rawStep.onFailure) + ? rawStep.onFailure + : defaultOnFailure(declaration.risk), + dormant: false, + }) + }) + + phases.push({ key, label: rawPhase.label, steps }) + }) + + if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`) + + if (errors.length) return { ok: false, errors } + return { ok: true, spec: { schedule, phases } } +} + +/** Every action id a spec names, dormant ones included. */ +const actionIdsIn = (spec) => + (spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean) + +/** + * A spec is publishable when nothing in it is dormant. + * + * Separate from `validate` on purpose: a dormant step must not stop an author + * SAVING (that is what makes an uninstall non-destructive), and it must stop + * them PUBLISHING, because publishing is what makes a version a thing runs are + * pinned to and a run cannot dispatch a verb nobody registers. + */ +function publishable(spec) { + const dormant = (spec?.phases || []) + .flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)) + return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] } +} + +/** An empty, valid spec — what a newly created draft carries. */ +const emptySpec = () => ({ + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [] }], +}) + +module.exports = { + validate, + publishable, + actionIdsIn, + emptySpec, + defaultOnFailure, + PHASE_KEY, + SCHEDULE_KINDS, + ON_FAILURE, + ON_FAILURE_BY_RISK, + MAX_PHASES, + MAX_STEPS_PER_PHASE, + MAX_STEPS, +} diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js new file mode 100644 index 0000000..0794cfe --- /dev/null +++ b/server/src/model/events/eventDefinitions.db.js @@ -0,0 +1,134 @@ +// ── event_definitions — SQL only ─────────────────────────────────────────── +// +// EVENTS.md §D. The `.db.js` half of the pair: parameterised SQL and hydration, +// no policy. Everything that decides whether a write is allowed lives in +// `eventDefinitions.model.js`. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => + row && { + ...row, + spec: parseJson(row.spec, null), + } + +// `current_version` is joined rather than stored: the list screen shows "v3" and +// the column that would hold it is a denormalisation of a row this query already +// has to reach for the publish date anyway. +const SELECT_LIST = ` + SELECT d.*, s.name AS series_name, s.slug AS series_slug, + v.version AS current_version + FROM event_definitions d + LEFT JOIN event_series s ON s.id = d.series_id + LEFT JOIN event_versions v ON v.id = d.current_version_id +` + +const list = async ({ state = null } = {}) => { + const rows = state + ? await query(`${SELECT_LIST} WHERE d.state = ? ORDER BY d.updated_at DESC, d.id DESC`, [state]) + : await query(`${SELECT_LIST} ORDER BY d.updated_at DESC, d.id DESC`) + return rows.map(hydrate) +} + +const getById = async (id) => { + const [row] = await query(`${SELECT_LIST} WHERE d.id = ?`, [id]) + return hydrate(row) +} + +const getBySlug = async (slug) => { + const [row] = await query(`${SELECT_LIST} WHERE d.slug = ?`, [slug]) + return hydrate(row) +} + +/** Does any OTHER definition hold this slug? The uniqueness pre-check. */ +const slugTaken = async (slug, exceptId = null) => { + const rows = exceptId + ? await query('SELECT id FROM event_definitions WHERE slug = ? AND id <> ?', [slug, exceptId]) + : await query('SELECT id FROM event_definitions WHERE slug = ?', [slug]) + return rows.length > 0 +} + +const insert = async (d) => { + const result = await query( + `INSERT INTO event_definitions + (title, slug, summary, body, image_url, owner_module, series_id, series_order, + concurrency_key, grace_seconds, timezone, spec, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + d.title, + d.slug, + d.summary, + d.body, + d.image_url, + d.owner_module, + d.series_id, + d.series_order, + d.concurrency_key, + d.grace_seconds, + d.timezone, + JSON.stringify(d.spec), + d.created_by, + d.created_by, + ], + ) + return result.insertId +} + +const update = (id, d) => + query( + `UPDATE event_definitions + SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?, + series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?, + spec = ?, updated_by = ? + WHERE id = ?`, + [ + d.title, + d.slug, + d.summary, + d.body, + d.image_url, + d.series_id, + d.series_order, + d.concurrency_key, + d.grace_seconds, + d.timezone, + JSON.stringify(d.spec), + d.updated_by, + id, + ], + ) + +/** + * Point a definition at the version it just published, and mark it `ready`. + * + * One statement, because the two halves are the same fact: `ready` means "a + * version has been published and the schedule is live" (§E), so a state without + * a `current_version_id` is a lie the scheduler would act on. + */ +const markReady = (id, versionId, userId) => + query( + `UPDATE event_definitions + SET state = 'ready', current_version_id = ?, updated_by = ? + WHERE id = ?`, + [versionId, userId, id], + ) + +/** + * Archive. Never a hard delete while runs reference it (§ API surface) — and the + * schema would refuse one anyway, because `event_runs.version_id` RESTRICTs. + * Archiving is what "delete" means on this screen, and the row keeps its history. + */ +const archive = (id, userId) => + query("UPDATE event_definitions SET state = 'archived', updated_by = ? WHERE id = ?", [userId, id]) + +module.exports = { + list, + getById, + getBySlug, + slugTaken, + insert, + update, + markReady, + archive, +} diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js new file mode 100644 index 0000000..be5008f --- /dev/null +++ b/server/src/model/events/eventDefinitions.model.js @@ -0,0 +1,270 @@ +// ── Event definitions — the save path ────────────────────────────────────── +// +// EVENTS.md §D and "Versioning, and editing a live event". A definition is +// operator-authored data, and this file is the boundary that decides whether a +// version of it may exist. The authoring UI (Phase 3, then Phase 13) will +// re-check some of this for the sake of a good inline error; that second copy is +// expected to drift, so this one is the one that decides, and a definition +// arriving by any other route gets the same answer. +// +// **The three rules with teeth, and each is a rule about time rather than about +// shape:** +// +// 1. Publishing SNAPSHOTS. It copies the working spec into an immutable +// `event_versions` row and points `current_version_id` at it. Editing +// afterwards is free and does not touch the row a live run pinned. +// 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module +// must not make an author's work uneditable, and it must not let a version be +// published that names a verb nobody can perform. +// 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so +// a hard delete of a definition that has ever run is refused by the database +// anyway — and the row's history is the thing an audit reads. + +const db = require('./eventDefinitions.db') +const versionsDb = require('./eventVersions.db') +const runsDb = require('./eventRuns.db') +const seriesDb = require('./eventSeries.db') +const spec = require('../../events/spec') +const { slugify, uniqueSlug } = require('../teams/teamSlug') +const { cleanBody } = require('../../utils/sanitizeHtml') + +const MAX_TITLE = 200 +const MAX_SUMMARY = 500 +const MAX_URL = 500 +const MAX_CONCURRENCY_KEY = 190 + +// A day either side of §D's default. Below a minute the grace window cannot +// survive a single slow boot; above a day a "missed" occurrence would start +// silently the following afternoon, which is the exact behaviour §E forbids. +const MIN_GRACE_SECONDS = 60 +const MAX_GRACE_SECONDS = 86_400 + +/** + * IANA zone names, checked against the platform's own database rather than a + * list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node + * ships the full tzdata — so this is the same check Phase 4's occurrence + * arithmetic will make, asked one screen earlier where an operator can fix it. + */ +function isTimezone(tz) { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }) + return true + } catch { + return false + } +} + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) + +const trimOrNull = (v, max) => { + if (v === undefined || v === null) return null + const s = String(v).trim() + return s === '' ? null : s.slice(0, max) +} + +/** + * Validate an incoming definition against the registries and the schema. + * + * `{ ok: true, definition }` with a normalised row ready for insert/update, or + * `{ ok: false, errors }` listing every problem rather than the first. + * + * `existing` is the row being edited, or null on a create. It is what lets a + * dormant step survive: the ids already in the saved spec widen what the spec + * validator will accept, so an uninstall is never destructive after the fact. + */ +async function validate(input, { existing = null } = {}) { + const errors = [] + const body = isPlainObject(input) ? input : {} + + const title = trimOrNull(body.title, MAX_TITLE) + if (!title) errors.push('title is required') + + const summary = trimOrNull(body.summary, MAX_SUMMARY) + const imageUrl = trimOrNull(body.imageUrl, MAX_URL) + const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY) + + // The storyline. Sanitized on write, exactly as a wiki page and a forum post + // are: it is author-supplied HTML that ends up on a public page. + const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body)) + + const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC' + if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`) + + const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds + const graceSeconds = Number(graceRaw) + if ( + !Number.isInteger(graceSeconds) || + graceSeconds < MIN_GRACE_SECONDS || + graceSeconds > MAX_GRACE_SECONDS + ) { + errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`) + } + + let seriesId = null + if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') { + seriesId = Number(body.seriesId) + if (!Number.isInteger(seriesId) || seriesId < 1) { + errors.push('seriesId must be an integer') + seriesId = null + } else if (!(await seriesDb.exists(seriesId))) { + // Checked here as well as by the foreign key, because a 1452 reaching a + // controller is a 500 and this is a 400 an author can act on. + errors.push(`seriesId ${seriesId} does not exist`) + seriesId = null + } + } else if (existing) { + seriesId = existing.series_id + } + + const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder + const seriesOrder = Number(seriesOrderRaw) + if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer') + + // ── the spec ── + const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec + const known = existing?.spec ? spec.actionIdsIn(existing.spec) : [] + const checked = spec.validate(rawSpec, { knownActionIds: known }) + if (!checked.ok) errors.push(...checked.errors) + + // ── the slug ── + // + // Derived from the title on create and FROZEN afterwards, like a Team's: the + // public event page lives at it, and a retitle must not break a link somebody + // posted in Discord. An author who genuinely needs a different address makes a + // new definition. + let slug = existing?.slug || null + if (!slug) { + const stem = slugify(title || '') || 'event' + const taken = (await db.list()).map((d) => d.slug) + slug = uniqueSlug(stem, taken, { fallback: 'event' }) + } + + if (errors.length) return { ok: false, errors } + + return { + ok: true, + definition: { + title, + slug, + summary, + body: storyline, + image_url: imageUrl, + owner_module: existing?.owner_module ?? null, + series_id: seriesId, + series_order: seriesOrder, + concurrency_key: concurrencyKey, + grace_seconds: graceSeconds, + timezone, + spec: checked.spec, + }, + } +} + +/** Create a draft. */ +async function create(input, userId) { + const result = await validate(input) + if (!result.ok) return result + const id = await db.insert({ ...result.definition, created_by: userId }) + return { ok: true, id, definition: await db.getById(id) } +} + +/** + * Edit a definition. + * + * An `archived` definition is not editable. That is the one state check here, + * and it is a real one rather than a formality: archiving is how a definition is + * retired, and a retired definition that can still be edited is a definition + * somebody will edit and then wonder why it never runs. + */ +async function save(id, input, userId) { + const existing = await db.getById(id) + if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } + if (existing.state === 'archived') { + return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] } + } + const result = await validate(input, { existing }) + if (!result.ok) return result + await db.update(id, { ...result.definition, updated_by: userId }) + return { ok: true, id, definition: await db.getById(id) } +} + +/** + * Publish: snapshot the working spec into an immutable version and go `ready`. + * + * The spec is re-validated here against the registries as they stand RIGHT NOW, + * not trusted from the save that wrote it. A module uninstalled between the two + * is the whole reason: the save was legitimate, and publishing a version whose + * steps name a verb nobody can perform would be a run that fails at dispatch + * with the world half-changed. + */ +async function publish(id, userId) { + const existing = await db.getById(id) + if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } + if (existing.state === 'archived') { + return { ok: false, status: 409, errors: ['an archived definition cannot be published'] } + } + + const checked = spec.validate(existing.spec, { + knownActionIds: spec.actionIdsIn(existing.spec), + }) + if (!checked.ok) return { ok: false, status: 400, errors: checked.errors } + + const publishable = spec.publishable(checked.spec) + if (!publishable.ok) { + return { + ok: false, + status: 409, + errors: [ + `cannot publish: no module registers ${publishable.dormant.join(', ')}`, + ], + } + } + if (!checked.spec.phases.some((p) => p.steps.length)) { + // An empty event publishes cleanly and then does nothing, which looks like a + // broken run rather than an empty one. Refusing costs an author one click and + // saves an operator a diagnosis. + return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] } + } + + const version = await versionsDb.nextVersion(id) + const versionId = await versionsDb.insert(id, version, checked.spec, userId) + await db.markReady(id, versionId, userId) + return { ok: true, versionId, version, definition: await db.getById(id) } +} + +/** + * Archive. + * + * Refused while a run of this definition is still in flight — not because the + * database would object (it would not; archiving is an UPDATE), but because the + * screen the run is on reads its title and state from here, and retiring a + * definition mid-run makes the console describe something that is no longer + * supposed to exist. Cancel the run, then archive. + */ +async function archive(id, userId) { + const existing = await db.getById(id) + if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } + if (existing.state === 'archived') return { ok: true, definition: existing } + + const active = await runsDb.countActiveForDefinition(id) + if (active > 0) { + return { + ok: false, + status: 409, + errors: [`cannot archive: ${active} run(s) of this definition are still in flight`], + } + } + await db.archive(id, userId) + return { ok: true, definition: await db.getById(id) } +} + +module.exports = { + validate, + create, + save, + publish, + archive, + isTimezone, + MIN_GRACE_SECONDS, + MAX_GRACE_SECONDS, +} diff --git a/server/src/model/events/eventJson.js b/server/src/model/events/eventJson.js new file mode 100644 index 0000000..56cebbf --- /dev/null +++ b/server/src/model/events/eventJson.js @@ -0,0 +1,23 @@ +// ── One JSON reader for the whole events model ───────────────────────────── +// +// JSON columns come back from the driver already parsed on some MariaDB/driver +// combinations and as a string on others — it depends on whether the column is a +// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as. Every +// read in this directory goes through this, so no caller has to know which it +// got. +// +// Lifted from `engagementRules.db.js`, which learned it first, and hoisted into +// its own file here rather than copied into six: six copies of a fallback is six +// chances for one of them to fall back to `{}` where the reader expects `[]`. + +function parseJson(value, fallback) { + if (value === null || value === undefined) return fallback + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return fallback + } +} + +module.exports = { parseJson } diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js new file mode 100644 index 0000000..a32732c --- /dev/null +++ b/server/src/model/events/eventRunLog.db.js @@ -0,0 +1,67 @@ +// ── event_run_log — SQL only ─────────────────────────────────────────────── +// +// EVENTS.md § Observability. "Why didn't phase 3 start?" must be a query, and +// `activity_log.detail` is TEXT and unqueryable, which is why this table exists +// beside the audit log rather than instead of it. Both are written: the audit of +// WHO published WHAT goes to `activity_log`, the diagnosis goes here. +// +// **`kind` is a closed set enforced here rather than an ENUM in the DDL.** The +// set grows with almost every later phase — conditions in Phase 5, cap draws in +// Phase 6, ledger movements in Phase 8 — and an ENUM change is a table alter +// this project has no migration system for. A constant in a file is the same +// guarantee with a cheaper hinge. + +const log = require('../../utils/logger')('events') +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +// Phase 1's kinds. Later phases append; nothing here is ever renamed, because a +// stored row would then name a kind no reader knows. +const KINDS = [ + 'run.created', // an occurrence was materialised + 'run.status', // a status transition, with from/to + 'phase.entered', // a phase's steps were materialised + 'step.status', // a step transition, with the module's answer + 'note', // a human action taken from the admin surface +] + +const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) } + +const listForRun = async (runId, { limit = 500 } = {}) => { + const n = Math.min(Math.max(Number(limit) || 500, 1), 2000) + return ( + await query(`SELECT * FROM event_run_log WHERE run_id = ? ORDER BY at DESC, id DESC LIMIT ${n}`, [ + runId, + ]) + ).map(hydrate) +} + +/** + * Write one line. **Never throws.** + * + * The diagnostic log is what an operator reads when something has already gone + * wrong, so a failure to write it must not become a second failure on top of the + * first — a runner that aborted a run because it could not record why would be + * the worst possible reading of "observability". The same posture + * `uoLinkClient.js` takes: answer, do not throw. + */ +async function write({ runId, stepId = null, kind, phase = null, detail = null }) { + if (!KINDS.includes(kind)) { + // A programming error, not an operational one, and it is louder than a + // silent drop for exactly that reason. + log.warn('event run log: unknown kind', { kind, runId }) + return false + } + try { + await query( + 'INSERT INTO event_run_log (run_id, step_id, kind, phase, detail) VALUES (?, ?, ?, ?, ?)', + [runId, stepId, kind, phase, detail === null ? null : JSON.stringify(detail)], + ) + return true + } catch (err) { + log.error('event run log write failed', { runId, kind, message: err.message }) + return false + } +} + +module.exports = { KINDS, listForRun, write } diff --git a/server/src/model/events/eventRunSteps.db.js b/server/src/model/events/eventRunSteps.db.js new file mode 100644 index 0000000..bf0a7dc --- /dev/null +++ b/server/src/model/events/eventRunSteps.db.js @@ -0,0 +1,95 @@ +// ── event_run_steps — SQL only ───────────────────────────────────────────── +// +// EVENTS.md §D and §E. Phase 1 materialises a run's steps and reads them back +// for the run console. **Draining them is Phase 2's**: the CAS claim, the lease, +// the attempt counter and the classification of a module's answer are the +// runner, and none of them is stubbed here. +// +// The one runtime property Phase 1 does have to get right is the idempotency key +// (§E). Core mints it ONCE, at materialisation, and it does NOT vary by attempt — +// a retry re-sends the same key so the game side can recognise the repeat. That +// makes it a property of the INSERT below rather than of the dispatch, which is +// the only reason it can be stable at all. + +const crypto = require('crypto') + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => row && { ...row, params: parseJson(row.params, {}) } + +/** + * `sha256(runId | stepId)`, truncated to 40 hex — the shape `shardEvents.dedupeKey` + * already uses, so the two dedupe keys on this codebase read alike. + * + * The step id is not known until the row exists, so materialisation inserts with + * a provisional key and stamps the real one immediately afterwards. That is one + * extra statement per step and it buys the property the whole retry story rests + * on: the key is a function of identity, never of attempt or of clock. + */ +const idempotencyKey = (runId, stepId) => + crypto.createHash('sha256').update(`${runId}|${stepId}`).digest('hex').slice(0, 40) + +const listForRun = async (runId) => + ( + await query( + 'SELECT * FROM event_run_steps WHERE run_id = ? ORDER BY phase, seq, id', + [runId], + ) + ).map(hydrate) + +const getById = async (id) => { + const [row] = await query('SELECT * FROM event_run_steps WHERE id = ?', [id]) + return hydrate(row) +} + +/** + * Materialise one phase's steps. + * + * `INSERT IGNORE` against `UNIQUE (run_id, phase, seq)`, so a tick that overran + * into the next one cannot double-materialise a phase — the same argument the + * occurrence key makes one table up, at the other end of the run. + * + * Returns the rows as they now stand, created or pre-existing, so a caller that + * lost the race still gets the step ids. + */ +const materialisePhase = async (runId, phase, steps) => { + for (let i = 0; i < steps.length; i++) { + const step = steps[i] + const result = await query( + `INSERT IGNORE INTO event_run_steps + (run_id, phase, seq, action_id, params, action_version, on_failure, idempotency_key) + VALUES (?, ?, ?, ?, ?, ?, ?, '')`, + [ + runId, + phase, + i, + step.actionId, + JSON.stringify(step.params || {}), + step.actionVersion || 1, + step.onFailure || 'pause', + ], + ) + if (Number(result?.affectedRows || 0) === 1) { + // Stamped in a second statement because the key is a function of the row's + // own id. Scoped by the empty key so a re-run of this loop over an existing + // phase can never overwrite a key a dispatch has already sent. + await query( + "UPDATE event_run_steps SET idempotency_key = ? WHERE id = ? AND idempotency_key = ''", + [idempotencyKey(runId, result.insertId), result.insertId], + ) + } + } + return listForRun(runId) +} + +/** The run console's summary line: how many steps sit in each status. */ +const statusCounts = async (runId) => { + const rows = await query( + 'SELECT status, COUNT(*) AS n FROM event_run_steps WHERE run_id = ? GROUP BY status', + [runId], + ) + return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)])) +} + +module.exports = { listForRun, getById, materialisePhase, statusCounts, idempotencyKey } diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js new file mode 100644 index 0000000..bf8a59f --- /dev/null +++ b/server/src/model/events/eventRuns.db.js @@ -0,0 +1,105 @@ +// ── event_runs — SQL only ────────────────────────────────────────────────── +// +// EVENTS.md §D and §E. Phase 1 writes exactly one kind of row — a `scheduled` +// occurrence — and reads them back for the admin surface. **The claim, the CAS +// transitions and the lease reclaim are Phase 2's** and are deliberately not +// stubbed here: a half-written claim is worse than no claim, because it reads as +// protection. +// +// What Phase 1 does own is the INSERT, and it owns the important half of it: +// materialisation is `INSERT IGNORE` against `UNIQUE (definition_id, scope, +// scheduled_for)`, so a second attempt at one occurrence writes nothing and +// answers honestly rather than raising a duplicate-key error a caller has to +// interpret. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) } + +const SELECT_LIST = ` + SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number + FROM event_runs r + JOIN event_definitions d ON d.id = r.definition_id + JOIN event_versions v ON v.id = r.version_id +` + +/** + * The admin run list. Newest occurrence first, across every definition. + * + * `limit` is interpolated after an integer coercion rather than bound, because + * MariaDB will not take a placeholder in LIMIT on a prepared statement. It never + * reaches SQL as anything but a number. + */ +const list = async ({ definitionId = null, status = null, limit = 100 } = {}) => { + const where = [] + const args = [] + if (definitionId) { + where.push('r.definition_id = ?') + args.push(definitionId) + } + if (status) { + where.push('r.status = ?') + args.push(status) + } + const clause = where.length ? `WHERE ${where.join(' AND ')}` : '' + const n = Math.min(Math.max(Number(limit) || 100, 1), 500) + const rows = await query( + `${SELECT_LIST} ${clause} ORDER BY r.scheduled_for DESC, r.id DESC LIMIT ${n}`, + args, + ) + return rows.map(hydrate) +} + +const getById = async (id) => { + const [row] = await query(`${SELECT_LIST} WHERE r.id = ?`, [id]) + return hydrate(row) +} + +/** + * Materialise one occurrence. Answers the row id, or `null` when one already + * existed — which is not an error and is the ordinary answer under a tick that + * overran into the next one. + */ +const materialise = async (run) => { + const result = await query( + `INSERT IGNORE INTO event_runs + (definition_id, version_id, scope, scheduled_for, timezone, concurrency_key, + params, rehearsal, started_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + run.definition_id, + run.version_id, + run.scope || '', + run.scheduled_for, + run.timezone || 'UTC', + run.concurrency_key, + run.params === null || run.params === undefined ? null : JSON.stringify(run.params), + run.rehearsal ? 1 : 0, + run.started_by, + ], + ) + return Number(result?.affectedRows || 0) === 1 ? result.insertId : null +} + +/** The occurrence the unique key names, whether or not this call created it. */ +const findOccurrence = async (definitionId, scope, scheduledFor) => { + const [row] = await query( + `${SELECT_LIST} WHERE r.definition_id = ? AND r.scope = ? AND r.scheduled_for = ?`, + [definitionId, scope || '', scheduledFor], + ) + return hydrate(row) +} + +/** Is anything of this definition not yet terminal? The archive pre-check. */ +const countActiveForDefinition = async (definitionId) => { + const [row] = await query( + `SELECT COUNT(*) AS n FROM event_runs + WHERE definition_id = ? + AND status IN ('scheduled','starting','running','paused','ending')`, + [definitionId], + ) + return Number(row?.n || 0) +} + +module.exports = { list, getById, materialise, findOccurrence, countActiveForDefinition } diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js new file mode 100644 index 0000000..34294c5 --- /dev/null +++ b/server/src/model/events/eventRuns.model.js @@ -0,0 +1,134 @@ +// ── Event runs — creating an occurrence ──────────────────────────────────── +// +// EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's +// steps. **It does not start anything**: there is no runner until Phase 2, so a +// row created here sits at `scheduled` indefinitely. That is the correct +// behaviour for this phase and it has to be VISIBLE as such rather than looking +// broken, which is why `create()` answers with the row and the admin surface +// renders the status verbatim. +// +// Two properties this file owns, both of which are the reason it exists before +// the runner rather than with it: +// +// - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two +// attempts at one occurrence produce one row and an honest answer, not a +// duplicate-key error a caller has to interpret. The unique index — not a +// claim — is what makes "one run per occurrence per scope" true (§E). +// - **The idempotency key is minted with the step row and never varies by +// attempt.** It is a function of identity, so it can only be stable if it is +// stamped where the identity is created. + +const db = require('./eventRuns.db') +const stepsDb = require('./eventRunSteps.db') +const logDb = require('./eventRunLog.db') +const definitionsDb = require('./eventDefinitions.db') +const versionsDb = require('./eventVersions.db') + +const MAX_SCOPE = 190 + +/** + * Render a definition's `concurrency_key` template against a run's params. + * + * `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A + * placeholder with no matching param is left standing rather than replaced with + * an empty string: `invasion:` would collide with every other unrendered key on + * the deployment, which is the opposite of what a concurrency key is for, and a + * literal `invasion:{region}` in the column is a visible mistake. + */ +function renderConcurrencyKey(template, params) { + if (!template) return null + return String(template) + .replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => { + const value = params && params[name] + return value === undefined || value === null || value === '' ? whole : String(value) + }) + .slice(0, MAX_SCOPE) +} + +/** + * Create one occurrence of a definition and materialise its first phase. + * + * `scheduledFor` defaults to now — "start now" is an occurrence whose instant is + * the present, not a separate concept, which is what keeps the runner's one + * materialise/advance path honest when Phase 4 adds recurrence on top. + */ +async function create(definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null } = {}, userId) { + const definition = await definitionsDb.getById(definitionId) + if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] } + if (definition.state !== 'ready') { + return { + ok: false, + status: 409, + errors: [`a ${definition.state} definition has no published version to run`], + } + } + if (!definition.current_version_id) { + return { ok: false, status: 409, errors: ['this definition has no published version'] } + } + + const version = await versionsDb.getById(definition.current_version_id) + if (!version?.spec?.phases?.length) { + return { ok: false, status: 409, errors: ['the published version has no phases'] } + } + + const scopeValue = String(scope || '').slice(0, MAX_SCOPE) + const when = scheduledFor ? new Date(scheduledFor) : new Date() + if (Number.isNaN(when.getTime())) { + return { ok: false, status: 400, errors: ['scheduledFor is not a date'] } + } + + const runId = await db.materialise({ + definition_id: definitionId, + version_id: version.id, + scope: scopeValue, + // Stored as UTC. The definition's zone is what an occurrence is COMPUTED in + // (Phase 4); what is stored is the instant. + scheduled_for: when, + timezone: definition.timezone, + concurrency_key: renderConcurrencyKey(definition.concurrency_key, params), + params, + rehearsal, + started_by: userId, + }) + + if (runId === null) { + // The occurrence already existed. Not an error — it is what the unique index + // is for — so the existing row is the answer. + const existing = await db.findOccurrence(definitionId, scopeValue, when) + return { ok: true, created: false, run: existing } + } + + await logDb.write({ + runId, + kind: 'run.created', + detail: { + definitionId, + versionId: version.id, + version: version.version, + scope: scopeValue, + rehearsal: Boolean(rehearsal), + by: userId, + }, + }) + + // The first phase's steps, materialised at creation rather than at start. + // Phase 2 materialises each LATER phase as the run enters it; doing the first + // one here is what makes a Phase 1 run row inspectable — an operator can see + // the steps that would run, with their params and their idempotency keys, + // before there is anything to run them. + const first = version.spec.phases[0] + await stepsDb.materialisePhase(runId, first.key, first.steps || []) + await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } }) + + return { ok: true, created: true, run: await db.getById(runId) } +} + +/** A run, its steps and its status counts — what the run console reads. */ +async function detail(runId) { + const run = await db.getById(runId) + if (!run) return null + const [steps, counts] = await Promise.all([stepsDb.listForRun(runId), stepsDb.statusCounts(runId)]) + return { run, steps, counts } +} + +module.exports = { create, detail, renderConcurrencyKey } diff --git a/server/src/model/events/eventSeries.db.js b/server/src/model/events/eventSeries.db.js new file mode 100644 index 0000000..2ed3729 --- /dev/null +++ b/server/src/model/events/eventSeries.db.js @@ -0,0 +1,23 @@ +// ── event_series — SQL only ──────────────────────────────────────────────── +// +// EVENTS.md §D. The arc a definition may belong to. Phase 1 needs the reads — +// `event_definitions.series_id` is a foreign key and the definition save path +// has to check it resolves — and creating one is Phase 4's, where the calendar +// is what makes an arc visible. + +const { query } = require('../../utils/db') + +const list = async () => + query('SELECT * FROM event_series ORDER BY ordering, name, id') + +const getById = async (id) => { + const [row] = await query('SELECT * FROM event_series WHERE id = ?', [id]) + return row || null +} + +const exists = async (id) => { + const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id]) + return Boolean(row) +} + +module.exports = { list, getById, exists } diff --git a/server/src/model/events/eventVersions.db.js b/server/src/model/events/eventVersions.db.js new file mode 100644 index 0000000..c77c207 --- /dev/null +++ b/server/src/model/events/eventVersions.db.js @@ -0,0 +1,54 @@ +// ── event_versions — SQL only ────────────────────────────────────────────── +// +// EVENTS.md §D. Immutable: there is an insert and there are reads, and there is +// deliberately no update and no delete. A run pins a version, and that pin is +// what makes the run reproducible and an audit answerable after the definition +// has been edited underneath it. + +const { query } = require('../../utils/db') +const { parseJson } = require('./eventJson') + +const hydrate = (row) => row && { ...row, spec: parseJson(row.spec, null) } + +const listForDefinition = async (definitionId) => + ( + await query( + `SELECT v.id, v.definition_id, v.version, v.published_at, v.published_by, u.username AS published_by_username + FROM event_versions v + LEFT JOIN users u ON u.id = v.published_by + WHERE v.definition_id = ? + ORDER BY v.version DESC`, + [definitionId], + ) + ).map((row) => row) + +const getById = async (id) => { + const [row] = await query('SELECT * FROM event_versions WHERE id = ?', [id]) + return hydrate(row) +} + +/** + * The next version number for a definition. + * + * Read separately and then INSERTed, which is a read-then-write — and it is safe + * only because `UNIQUE (definition_id, version)` is behind it. Two publishes + * racing for version 4 is one 1062 the caller reports, not two rows called 4. + * The unique index is the mechanism; this query is the ergonomics. + */ +const nextVersion = async (definitionId) => { + const [row] = await query( + 'SELECT COALESCE(MAX(version), 0) + 1 AS next FROM event_versions WHERE definition_id = ?', + [definitionId], + ) + return Number(row?.next || 1) +} + +const insert = async (definitionId, version, spec, userId) => { + const result = await query( + 'INSERT INTO event_versions (definition_id, version, spec, published_by) VALUES (?, ?, ?, ?)', + [definitionId, version, JSON.stringify(spec), userId], + ) + return result.insertId +} + +module.exports = { listForDefinition, getById, nextVersion, insert } diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 2831359..950014e 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -124,6 +124,20 @@ const triggers = new Map() // trigger of the same name would be a collision between two unrelated things. const audiences = new Map() +// action id → { owner, id, label, description, risk, reversible, version, +// budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1). +// +// **Its own id space**, like `audiences` above and for the same kind of reason: +// an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as +// the thing a module can be asked to do and `uo.champ.start` as the thing that +// happened are two unrelated declarations that must not collide with — or +// silently satisfy — each other. Nothing cross-checks this map against the +// stream/trigger namespace, and nothing should. +// +// A Map, and read by id on the dispatch path exactly as `triggers` is; insertion +// order is what the admin catalog renders in. +const eventActions = new Map() + // owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b, // decision 7). What a module ships as CONTENT rather than as contract: the // bodies its triggers render through, and the rules an operator switches on. @@ -163,6 +177,10 @@ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/ // Audiences are their own id space (see the `audiences` Map), so they get their // own constant even though the grammar is the same one. const AUDIENCE_ID = EVENT_ID +// Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id +// spaces"). One grammar, three namespaces — the constant is what makes the +// namespace visible at every use site. +const ACTION_ID = EVENT_ID // A module's claim must carry its id. Core's ids are its own namespace, and the // grandfathered names are the ones that predate all of this. @@ -367,6 +385,33 @@ async function resolveAudience(id, params = {}) { } } +// ── Event actions (EVENTS.md §F) ─────────────────────────────────────────── + +/** + * Every declaration WITHOUT its callables — what the admin catalog serves. + * + * `perform`, `revert` and `cost` are stripped for the same reason `resolve` is + * stripped from an audience and `handler` from a slash command: this is the + * object that leaves the process, and the browser's whole relationship with an + * action is naming one by id. §F's "a module registers actions server-side and + * adds no routes for them" is only true if the functions never ride out. + */ +const allEventActions = () => + [...eventActions.values()].map(({ perform, revert, cost, ...rest }) => rest) + +/** One declaration, callables included. The runner's lookup (Phase 2). */ +const eventAction = (id) => eventActions.get(id) || null + +/** + * Does anyone register this id right now? + * + * The authoring path's question, and it is deliberately not `eventAction(id) !== + * null` at every call site: a step naming an action whose module is uninstalled + * is DORMANT, not an error (§F), and the difference between "never existed" and + * "not installed today" is a distinction only the caller can draw. + */ +const isEventAction = (id) => eventActions.has(id) + // ── Shape checks, run the moment a registrant calls ──────────────────────── // // Split from the collision checks below on the same line PR 3 drew through @@ -745,6 +790,170 @@ function checkAudienceShape(entry) { } } +// ── Event action shape (EVENTS.md §F) ────────────────────────────────────── + +// Four values, closed, core-owned (§N6). Deliberately NOT "world-read" and +// "world-write", which are game words a chess ladder has no use for — and +// deliberately not extensible by a module, because the class is what core +// derives a step's `on_failure` from (§L) and a module that could invent +// `harmless` would be choosing its own retry policy. +const ACTION_RISKS = ['notify', 'inspect', 'change', 'irreversible'] + +// What core must know in order to clean up after a run (§L). `none` is gone once +// done; `self` undoes itself; `ledger` needs a `revert` over the rows core +// recorded; `override` is a lease, whose baseline core restores. +const ACTION_REVERSIBLE = ['none', 'self', 'ledger', 'override'] + +// The same six types a trigger variable uses. One vocabulary over both, because +// the authoring form that renders an action param and the template editor that +// renders a trigger variable are the same widget over the same six types, and a +// second list is a list that drifts. +const ACTION_PARAM_TYPES = VARIABLE_TYPES + +// The param name grammar, shared with trigger variables for the same reason: a +// param ends up as a key in a JSON object an operator reads. +const PARAM_NAME = VARIABLE_NAME + +// The default per-invocation deadline. Ten seconds is §F's own figure and it is +// the number the sidecar's own request timeout is set near — long enough for a +// round trip through a module, a sidecar and a game tick, short enough that a +// wedged action does not hold a step's claim past its lease. +const DEFAULT_BUDGET_MS = 10_000 +// An hour. Not "unlimited by another name": the bound exists so that a typo in a +// declaration is a slow action rather than a step that never times out at all, +// and Phase 2's lease has to be longer than this to mean anything. +const MAX_BUDGET_MS = 3_600_000 + +function checkActionParam(actionId, entry, seen) { + const { name, type, required, example, description, source } = entry || {} + const where = `registerEventActions: ${actionId}` + if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`) + if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`) + seen.add(name) + if (!ACTION_PARAM_TYPES.includes(type)) { + throw new Error(`${where}: param "${name}" has unsupported type "${type}"`) + } + // REQUIRED, on every param including the optional ones, and it is the same + // argument `checkTriggerVariable` makes: without it the authoring form has no + // placeholder and the operator is typing into a blank box, which is exactly + // how an unattended world write comes to be scheduled with a typo in it. It is + // one word at declaration time and unreconstructable afterwards. + if (example === undefined || example === null || example === '') { + throw new Error(`${where}: param "${name}" needs an example (it is the authoring placeholder)`) + } + // A `source` names a module-served option endpoint, so the field is a dropdown + // of real values rather than a text box (§F "Param option sources"). It is + // checked as an id here and resolved nowhere yet — the endpoint that answers it + // is Phase 7's, and a `source` naming nothing degrades the field to free text + // with a warning rather than blocking the form. + if (source !== undefined && !ACTION_ID.test(source || '')) { + throw new Error(`${where}: param "${name}" has a bad option source "${source}"`) + } + return { + name, + type, + required: Boolean(required), + example, + source: source === undefined ? null : source, + description: description || '', + } +} + +/** + * `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert }])`. + * + * A typed verb core may ask a registrant to carry out. Everything decidable from + * the argument alone is decided here, at the call; the collision — is this id + * already someone's action? — waits for `apply()`. + * + * The copy at the end is explicit rather than a spread, like every other shape + * check in this file: this object is served to the admin catalog and is what a + * step's params are validated against, so anything not named here is not part of + * the contract and must not ride along. + * + * **Nothing here executes and nothing here may touch the database.** Registration + * runs under `routeManifest.js` and `swagger.js` against a dead pool + * (MODULE_API.md §2.2), and core's own registration is subject to the same rule + * as a module's. + */ +function checkEventActionShape(entry) { + const a = entry || {} + if (!ACTION_ID.test(a.id || '')) { + throw new Error(`registerEventActions: bad action id "${a.id}"`) + } + if (!a.label) throw new Error(`registerEventActions: action "${a.id}" has no label`) + + // Both required with no default, for the reason a trigger's ceiling is: there + // is no safe value to guess. Defaulting `risk` to `notify` would give a world + // write the retry policy of a broadcast, and defaulting `reversible` to `none` + // would tell the cleanup generator there is nothing to undo. + if (!ACTION_RISKS.includes(a.risk)) { + throw new Error( + `registerEventActions: ${a.id} needs a risk class, one of ${ACTION_RISKS.join(', ')}`, + ) + } + if (!ACTION_REVERSIBLE.includes(a.reversible)) { + throw new Error( + `registerEventActions: ${a.id} needs a reversible class, one of ${ACTION_REVERSIBLE.join(', ')}`, + ) + } + + if (typeof a.perform !== 'function') { + throw new Error(`registerEventActions: ${a.id} has no perform()`) + } + // §F: `revert` is required iff `reversible === 'ledger'`. Checked here rather + // than discovered at teardown, because the moment it matters is the moment a + // run has already created something and the answer "there is no undo" is the + // one answer cleanup cannot act on. + if (a.reversible === 'ledger' && typeof a.revert !== 'function') { + throw new Error(`registerEventActions: ${a.id} is reversible: 'ledger' but has no revert()`) + } + // The mirror check, and it is not pedantry: a `revert` on a `reversible: + // 'none'` action is a module author who believes their action can be undone + // and a cleanup generator that will never call it. Silence there is a promise + // core does not keep. + if (a.revert !== undefined && a.reversible !== 'ledger') { + throw new Error( + `registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`, + ) + } + if (a.cost !== undefined && typeof a.cost !== 'function') { + throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`) + } + + const version = a.version === undefined ? 1 : a.version + if (!Number.isInteger(version) || version < 1) { + throw new Error(`registerEventActions: ${a.id} has a bad version "${a.version}"`) + } + + const budgetMs = a.budgetMs === undefined ? DEFAULT_BUDGET_MS : a.budgetMs + if (!Number.isInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_BUDGET_MS) { + throw new Error( + `registerEventActions: ${a.id} budgetMs must be 1..${MAX_BUDGET_MS} ms, got "${a.budgetMs}"`, + ) + } + + if (a.params !== undefined && !Array.isArray(a.params)) { + throw new Error(`registerEventActions: ${a.id} params must be an array`) + } + const seen = new Set() + const params = (a.params || []).map((p) => checkActionParam(a.id, p, seen)) + + return { + id: a.id, + label: a.label, + description: a.description || '', + risk: a.risk, + reversible: a.reversible, + version, + budgetMs, + params, + cost: a.cost || null, + perform: a.perform, + revert: a.revert || null, + } +} + // ── Engagement seeds (Phase 11b, decision 7) ─────────────────────────────── // // **Two mechanisms, and the asymmetry between them is the whole design.** @@ -983,6 +1192,7 @@ function stage(owner) { slashCommands: [], triggers: [], audiences: [], + eventActions: [], engagementSeeds: [], } return { @@ -1015,6 +1225,16 @@ function stage(owner) { if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array') for (const e of entries) staged.audiences.push(checkAudienceShape(e)) }, + // EVENTS.md §F, Phase 1. Present on the staging area from this phase and + // reached ONLY by `registerCore()` below — `loader.js` builds its own `api` + // facade and has no method that delegates here, so a module cannot call this + // yet. Phase 7 adds that facade and bumps MODULE_API to 1.10.0; until then + // the seam is exercised on every boot by core's own three actions and by + // nothing else, which is the point of registering them through it. + registerEventActions(entries) { + if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array') + for (const e of entries) staged.eventActions.push(checkEventActionShape(e)) + }, registerEngagementSeeds(entry) { staged.engagementSeeds.push(checkEngagementSeeds(owner, entry)) }, @@ -1040,6 +1260,7 @@ function apply({ slashCommands: newSlashCommands = [], triggers: newTriggers = [], audiences: newAudiences = [], + eventActions: newEventActions = [], engagementSeeds: newSeeds = [], }) { // ── validate ── @@ -1096,6 +1317,23 @@ function apply({ seenAudiences.add(a.id) } + // Actions, against their OWN map and nothing else. No cross-facet check with + // streams or triggers: an action id and a trigger id are different namespaces + // (§F), so `uo.champ.start` may legitimately be both a verb and an event, and + // reading a collision there would forbid the most natural pair of names a + // module will ever want. No legacy allowlist either — nothing predates this, + // so the prefix rule has no exceptions and should never grow one. + const seenActions = new Set() + for (const a of newEventActions) { + const held = eventActions.get(a.id) + if (held) throw new Error(`event action "${a.id}" is already registered by "${held.owner}"`) + if (seenActions.has(a.id)) throw new Error(`event action "${a.id}" registered twice`) + if (!namespaced(owner, a.id, {})) { + throw new Error(`event action "${a.id}" is not namespaced "${owner}."`) + } + seenActions.add(a.id) + } + const seenLegs = new Set() for (const l of newLegs) { const held = legs.get(l.leg) @@ -1160,6 +1398,7 @@ function apply({ for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c }) for (const t of newTriggers) triggers.set(t.id, { owner, ...t }) for (const a of newAudiences) audiences.set(a.id, { owner, ...a }) + for (const a of newEventActions) eventActions.set(a.id, { owner, ...a }) for (const seeds of newSeeds) engagementSeeds.set(owner, seeds) } @@ -1182,6 +1421,7 @@ function registerCore() { /* eslint-disable global-require */ const coreStreams = require('../config/coreStreams') const coreTriggers = require('../config/coreTriggers') + const coreEventActions = require('../config/coreEventActions') const discordLeg = require('../utils/discordAnnounce') /* eslint-enable global-require */ @@ -1192,6 +1432,11 @@ function registerCore() { // its five stream ids — the same-owner upgrade the one-namespace rule above is // written for — so this batch exercises the cross-facet check on every boot. api.registerEventTriggers(coreTriggers.TRIGGERS) + // The event contract (EVENTS.md §F, Phase 1). Core registers `core.announce`, + // `core.wait` and `core.cue` through the SAME staging area Phase 7 will hand a + // module, so the registry is exercised on every boot long before a module uses + // it — the argument registerCore() has made since the module system's Phase 3. + api.registerEventActions(coreEventActions.ACTIONS) // The three lines that used to follow — the shard stream catalog, the town // crier leg and the `admin.users.detail` filling — were shard CONTENT held @@ -1205,6 +1450,7 @@ function registerCore() { log.info('core registrations complete', { streams: streams.length, eventTriggers: triggers.size, + eventActions: eventActions.size, announceLegs: legs.size, extensions: [...slots.keys()].filter(slotFilledBy), }) @@ -1236,6 +1482,7 @@ function _reset() { slashCommands.clear() triggers.clear() audiences.clear() + eventActions.clear() engagementSeeds.clear() coreRegistered = false } @@ -1264,11 +1511,18 @@ module.exports = { allAudiences, audience, resolveAudience, + allEventActions, + eventAction, + isEventAction, allEngagementSeeds, engagementSeedsFor, SEEDABLE_CHANNELS, VARIABLE_TYPES, TRIGGER_KINDS, + ACTION_RISKS, + ACTION_REVERSIBLE, + ACTION_PARAM_TYPES, + DEFAULT_BUDGET_MS, stage, apply, registerCore, diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js new file mode 100644 index 0000000..7080dee --- /dev/null +++ b/server/src/router/v1/admin/events.controller.js @@ -0,0 +1,315 @@ +// ── 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. +// +// **What is deliberately absent**: pause, resume, advance, cancel, step +// skip/retry/confirm, cleanup and the action switchboard. Each of them acts on a +// run in flight, and nothing is in flight until Phase 2 builds the runner. A +// control that returns 200 and does nothing is worse than one that is not there. + +const registries = require('../../../modules/registries') +const spec = require('../../../events/spec') +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 runsDb = require('../../../model/events/eventRuns.db') +const runs = require('../../../model/events/eventRuns.model') +const logDb = require('../../../model/events/eventRunLog.db') +const activity = require('../../../model/activity/activity.model') + +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, + seriesId: d.series_id, + seriesName: d.series_name, + seriesOrder: d.series_order, + concurrencyKey: d.concurrency_key, + graceSeconds: d.grace_seconds, + timezone: d.timezone, + 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, + lastError: r.last_error, + createdAt: r.created_at, +}) + +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, + 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, + limits: { + maxPhases: spec.MAX_PHASES, + maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE, + maxSteps: spec.MAX_STEPS, + defaultBudgetMs: registries.DEFAULT_BUDGET_MS, + }, + }) +} + +/** GET /api/v1/admin/events/series */ +exports.listSeries = async (_req, res) => { + const rows = await seriesDb.list() + res.json({ + series: rows.map((s) => ({ + id: s.id, + name: s.name, + slug: s.slug, + description: s.description, + ordering: s.ordering, + })), + }) +} + +/** 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, + }) +} + +/** 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) + 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) + 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 }, + }) + return res.json({ + event: shapeDefinition(result.definition), + version: result.version, + versionId: result.versionId, + }) +} + +/** 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, + }) +} diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js new file mode 100644 index 0000000..36c28b6 --- /dev/null +++ b/server/src/router/v1/admin/events.router.js @@ -0,0 +1,201 @@ +// 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. `verify` (admin, editor) is Phase 6's, and the live run +// controls (admin, moderator) are Phase 3's — neither is stubbed here, because +// nothing is in flight until Phase 2 builds the runner. +// +// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and +// `/runs` are never read as an event id. + +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') + +// ── 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.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The registered actions and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { 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" } }, limits: { type: "object", additionalProperties: true } } } } } } */ + /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + controller.catalog, +) + +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. Read-only in this phase: creating and ordering one arrives with the calendar.' + // #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, +) + +// ── 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.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The run, its steps and the status counts', 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 } } } } } } */ + /* #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, +) + +// ── 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/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.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { 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 diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index f4bf0e2..8d0f668 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -31,6 +31,7 @@ const discordBotRouter = require('./discordBot.router') const settingsRouter = require('./settings.router') const modulesRouter = require('./modules.router') const engagementRouter = require('./engagement.router') +const eventsRouter = require('./events.router') const teamsRouter = require('./teams.router') const teamsVoiceRouter = require('./teamsVoice.router') const dashboardRouter = require('./dashboard.router') @@ -87,6 +88,13 @@ adminRouter.use('/modules', modulesRouter) // /modules above and for a related reason: this is the surface that decides who // the site sends mail to. adminRouter.use('/engagement', engagementRouter) +// The Event System (EVENTS.md § API surface, Phase 1). Staff-wide for the reads +// and gated per route for the writes, which is where §N2's asymmetry lives: +// publish and start are `admin` ONLY, while the live run controls Phase 3 adds +// are `admin` + `moderator`. Starting commits the deployment to an unattended +// world change; cancelling is incident response, and they are deliberately not +// the same gate. +adminRouter.use('/events', eventsRouter) // Teams. Staff-wide, like /activity: a moderator runs the reserved-name review // queue. The three actions that PUBLISH untrusted game-sourced strings are gated // per request inside the controller, not per route — a moderator may call them, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 9ce1599..608068f 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3363,6 +3363,1093 @@ ] } }, + "/api/v1/admin/events": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List every event definition with its state and current version", + "description": "", + "parameters": [ + { + "name": "state", + "in": "query", + "description": "Only definitions in this state: draft, ready or archived", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The definitions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Create a draft event definition", + "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.", + "responses": { + "201": { + "description": "The created draft", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; every problem is listed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin or editor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "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" + ] + } + } + } + } + } + }, + "/api/v1/admin/events/catalog": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List every registered event action, with its param schema, risk class and reversibility", + "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.", + "responses": { + "200": { + "description": "The registered actions and the vocabularies over them", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "actions": { + "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" + } + }, + "limits": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/runs": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List event runs across every definition, newest occurrence first", + "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.", + "parameters": [ + { + "name": "definitionId", + "in": "query", + "description": "Only runs of this definition", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "status", + "in": "query", + "description": "Only runs in this status", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "How many rows, 1..500 (default 100)", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The runs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/runs/{runId}": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "One run: its status, health, cleanup state and every step with its params and idempotency key", + "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.", + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The run, its steps and the status counts", + "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 + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/runs/{runId}/log": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "The diagnostic log for one run", + "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.", + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "How many lines, 1..2000 (default 500)", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "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" + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/series": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "List the event series a definition may belong to", + "description": "A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.", + "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" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Not staff", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/{id}": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "One event definition, including its working spec", + "description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The definition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such definition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Events" + ], + "summary": "Edit a definition and its working spec", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The saved definition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Validation failed; every problem is listed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "409": { + "description": "The definition is archived", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "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 + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Events" + ], + "summary": "Archive a definition — never a hard delete", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The archived definition", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "A run of this definition is still in flight", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/{id}/publish": { + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Snapshot the working spec into an immutable version and mark the definition ready", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The definition, now ready, and the version that was cut", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event": { + "type": "object", + "additionalProperties": true + }, + "version": { + "type": "integer" + }, + "versionId": { + "type": "integer" + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/events/{id}/runs": { + "post": { + "tags": [ + "Admin · Events" + ], + "summary": "Create an occurrence of a published definition", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "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" + } + } + } + } + } + }, + "201": { + "description": "The occurrence was created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "run": { + "type": "object", + "additionalProperties": true + }, + "created": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "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" + } + } + } + } + } + } + } + }, + "/api/v1/admin/events/{id}/versions": { + "get": { + "tags": [ + "Admin · Events" + ], + "summary": "The version history of one definition", + "description": "Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The versions, newest first", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "versions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such definition", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/invites": { "post": { "tags": [ diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js new file mode 100644 index 0000000..c3882e1 --- /dev/null +++ b/server/test/eventActionRegistry.test.js @@ -0,0 +1,196 @@ +// ── The event action registry (EVENTS.md §F, Phase 1) ────────────────────── +// +// Phase 1's acceptance criteria for the registry half, one test apiece: +// +// • core's three actions register on every boot and appear in the catalog +// • the catalog never carries a callable — no `perform`, `revert` or `cost` +// • a module registering an un-namespaced action fails, with the holder named +// • the closed sets are closed: an invented risk or reversibility is refused +// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION, +// not discovered at teardown when something has already been created +// • an action id and a trigger id are DIFFERENT namespaces, so one id may +// legitimately be both — the property the audience registry established and +// this one inherits +// +// Point the DB at a closed port BEFORE requiring anything: registries.js reaches +// utils/discordAnnounce, which reaches the pool at require time. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const coreEventActions = require('../src/config/coreEventActions') +const db = require('../src/utils/db') + +after(() => db.close()) + +beforeEach(() => registries._reset()) +afterEach(() => registries._reset()) + +const ok = (over = {}) => ({ + id: 'demo.thing.do', + label: 'Do the thing', + risk: 'change', + reversible: 'none', + perform: async () => ({ ok: true }), + ...over, +}) + +const register = (owner, entries) => { + const api = registries.stage(owner) + api.registerEventActions(entries) + registries.apply(api.staged) +} + +test('core registers its three actions on every boot', () => { + registries.registerCore() + const ids = registries.allEventActions().map((a) => a.id) + assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue']) + assert.equal(ids.length, coreEventActions.ACTIONS.length) +}) + +test('the catalog carries no callable', () => { + registries.registerCore() + for (const action of registries.allEventActions()) { + assert.equal(action.perform, undefined, `${action.id} leaked perform`) + assert.equal(action.revert, undefined, `${action.id} leaked revert`) + assert.equal(action.cost, undefined, `${action.id} leaked cost`) + } + // …and the runner's own lookup still has it, which is the half that makes the + // stripping a boundary rather than a deletion. + assert.equal(typeof registries.eventAction('core.wait').perform, 'function') +}) + +test('core placeholders refuse rather than claiming success', async () => { + // Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters + // because `ok: true` on an action that did nothing is a recorded world change + // that did not occur — the one wrong answer a stub can give. + registries.registerCore() + for (const id of ['core.announce', 'core.wait', 'core.cue']) { + const answer = await registries.eventAction(id).perform({}) + assert.equal(answer.ok, false) + assert.equal(answer.retry, false) + assert.match(answer.error, new RegExp(id.replace('.', '\\.'))) + } +}) + +test('an action must be namespaced to its owner, and the holder is named', () => { + assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/) + + register('demo', [ok()]) + assert.throws( + () => register('rival', [ok({ id: 'demo.thing.do' })]), + /already registered by "demo"/, + ) +}) + +test('the same id twice in one batch is refused', () => { + assert.throws(() => register('demo', [ok(), ok()]), /registered twice/) +}) + +test('risk and reversibility are closed sets with no default', () => { + assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/) + assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/) + assert.throws( + () => register('demo', [ok({ reversible: undefined })]), + /needs a reversible class/, + ) + assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/) +}) + +test("reversible: 'ledger' without revert() is refused at registration", () => { + assert.throws( + () => register('demo', [ok({ reversible: 'ledger' })]), + /is reversible: 'ledger' but has no revert\(\)/, + ) + // And the mirror: a revert() nothing will ever call is a promise core does not + // keep, so it is refused just as loudly. + assert.throws( + () => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]), + /declares revert\(\) but is reversible: 'none'/, + ) + register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })]) + assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function') +}) + +test('perform() is required and cost must be a function', () => { + assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/) + assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/) +}) + +test('every param needs a type and an example', () => { + const withParams = (params) => ok({ params }) + assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/) + assert.throws( + () => register('demo', [withParams([{ name: 'x', type: 'int' }])]), + /needs an example/, + ) + assert.throws( + () => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]), + /bad param name/, + ) + assert.throws( + () => + register('demo', [ + withParams([ + { name: 'x', type: 'int', example: 1 }, + { name: 'x', type: 'int', example: 2 }, + ]), + ]), + /declared twice/, + ) + register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])]) + const [param] = registries.eventAction('demo.thing.do').params + assert.deepEqual(param, { + name: 'x', + type: 'int', + required: false, + example: 12, + source: 'demo.options.x', + description: '', + }) +}) + +test('budgetMs defaults, and is bounded', () => { + register('demo', [ok()]) + assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS) + registries._reset() + assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/) + assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/) +}) + +test('actions and triggers are different namespaces, so one id may be both', () => { + // The property §F states and the audience registry established first. A verb + // called `demo.raid.start` and an event called `demo.raid.start` are two + // unrelated declarations, and forbidding the pair would forbid the most + // natural names a module will ever want. + const api = registries.stage('demo') + api.registerEventTriggers([ + { id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' }, + ]) + api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })]) + registries.apply(api.staged) + + assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started') + assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid') +}) + +test('a whole batch is refused or taken, never half', () => { + assert.throws( + () => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]), + /needs a risk class/, + ) + // The shape check throws at the CALL, before anything is staged, so nothing + // from the batch is visible. + assert.equal(registries.eventAction('demo.thing.do'), null) +}) + +test('_reset() hands the process back', () => { + registries.registerCore() + assert.equal(registries.allEventActions().length, 3) + registries._reset() + assert.equal(registries.allEventActions().length, 0) + assert.equal(registries.isEventAction('core.wait'), false) +}) diff --git a/server/test/eventSpec.test.js b/server/test/eventSpec.test.js new file mode 100644 index 0000000..307cdad --- /dev/null +++ b/server/test/eventSpec.test.js @@ -0,0 +1,231 @@ +// ── The event spec validator (EVENTS.md §C/§D, Phase 1) ──────────────────── +// +// The boundary that decides whether a version may exist. Its interesting cases +// are all about time rather than shape: +// +// • a step's params are checked against the action's DECLARED params, and the +// action version it was authored against is captured at save +// • `on_failure` is defaulted from the risk class, because a `change` action +// that fell back to `skip` would advance a run over a half-changed world +// • an unregistered action is refused on a NEW step and KEPT on an existing +// one — the rule `engagementRules.model` established for a dormant trigger, +// for the same reason: an uninstall must not be destructive after the fact +// • a dormant step blocks a PUBLISH and never a SAVE +// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)` +// would silently collapse them into one at materialisation +// • a key a later phase owns (`advance`, `announcements`) is REFUSED rather +// than preserved, so no corpus of unvalidated specs accumulates +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const spec = require('../src/events/spec') +const db = require('../src/utils/db') + +after(() => db.close()) + +beforeEach(() => { + registries._reset() + registries.registerCore() + const api = registries.stage('demo') + api.registerEventActions([ + { + id: 'demo.world.change', + label: 'Change the world', + risk: 'change', + reversible: 'none', + version: 4, + params: [ + { name: 'region', type: 'string', required: true, example: 'Yew' }, + { name: 'count', type: 'int', required: true, example: 12 }, + { name: 'hue', type: 'int', required: false, example: 1157 }, + ], + perform: async () => ({ ok: true }), + }, + { + id: 'demo.world.wreck', + label: 'Wreck the world', + risk: 'irreversible', + reversible: 'none', + perform: async () => ({ ok: true }), + }, + ]) + registries.apply(api.staged) +}) +afterEach(() => registries._reset()) + +const oneStep = (step) => ({ + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [step] }], +}) + +test('the empty spec is valid, and is what a new draft carries', () => { + const result = spec.validate(spec.emptySpec()) + assert.equal(result.ok, true) + assert.equal(result.spec.phases.length, 1) + assert.deepEqual(result.spec.schedule, { kind: 'manual' }) +}) + +test('params are checked against the declaration and coerced', () => { + const result = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 12 } }), + ) + assert.equal(result.ok, true) + const [step] = result.spec.phases[0].steps + assert.deepEqual(step.params, { region: 'Yew', count: 12 }) + // Captured from the declaration, not from the request: it is what lets a later + // bump warn in the editor instead of dispatching a mistyped parameter. + assert.equal(step.actionVersion, 4) + assert.equal(step.dormant, false) +}) + +test('a missing required param, a wrong type and an unknown param are all refused', () => { + const missing = spec.validate(oneStep({ actionId: 'demo.world.change', params: { region: 'Yew' } })) + assert.equal(missing.ok, false) + assert.match(missing.errors.join('\n'), /"count" is required/) + + const wrong = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 'twelve' } }), + ) + assert.equal(wrong.ok, false) + assert.match(wrong.errors.join('\n'), /"count" expected an integer/) + + // An unknown param is an ERROR, not a silent drop: an author who typed + // `regions` has written a step that would dispatch with the region missing, + // and dropping the key makes that look like it saved cleanly. + const typo = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { regions: 'Yew', count: 1 } }), + ) + assert.equal(typo.ok, false) + assert.match(typo.errors.join('\n'), /"regions" is not a param of demo\.world\.change/) +}) + +test('on_failure is defaulted from the risk class', () => { + const notify = spec.validate( + oneStep({ actionId: 'core.announce', params: { leg: 'discord', body: 'hi' } }), + ) + assert.equal(notify.spec.phases[0].steps[0].onFailure, 'skip') + + const change = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 1 } }), + ) + assert.equal(change.spec.phases[0].steps[0].onFailure, 'pause') + + const irreversible = spec.validate(oneStep({ actionId: 'demo.world.wreck' })) + assert.equal(irreversible.spec.phases[0].steps[0].onFailure, 'abort_run') + + // An author may still choose, within the closed set. + const chosen = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'skip' })) + assert.equal(chosen.spec.phases[0].steps[0].onFailure, 'skip') + const invented = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'shrug' })) + assert.equal(invented.ok, false) + assert.match(invented.errors.join('\n'), /onFailure: must be one of/) +}) + +test('a NEW step may not name an unregistered action', () => { + const result = spec.validate(oneStep({ actionId: 'gone.module.verb' })) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), /no module registers "gone\.module\.verb"/) +}) + +test('an EXISTING step keeps its action when the module goes away, and is marked dormant', () => { + const saved = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }), + ).spec + + // The module is uninstalled between one save and the next. + registries._reset() + registries.registerCore() + + const again = spec.validate(saved, { knownActionIds: spec.actionIdsIn(saved) }) + assert.equal(again.ok, true, again.errors && again.errors.join('\n')) + const [step] = again.spec.phases[0].steps + assert.equal(step.dormant, true) + // Params pass through untouched: the only thing that could validate them left + // with the module. + assert.deepEqual(step.params, { region: 'Yew', count: 3 }) + + // …and that is exactly what publish refuses. + const publishable = spec.publishable(again.spec) + assert.equal(publishable.ok, false) + assert.deepEqual(publishable.dormant, ['demo.world.change']) +}) + +test('validate accepts its own output — a saved spec is re-validated on every save', () => { + // The property the dormancy test above found the hard way: `validate` adds + // `actionVersion` and `dormant`, and a validator that then refused its own + // fields would make the SECOND save of any definition impossible, and publish + // — which re-validates before snapshotting — impossible full stop. + const once = spec.validate( + oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }), + ) + const twice = spec.validate(once.spec) + assert.equal(twice.ok, true, twice.errors && twice.errors.join('\n')) + assert.deepEqual(twice.spec, once.spec) +}) + +test('two phases may not share a key', () => { + const result = spec.validate({ + schedule: { kind: 'manual' }, + phases: [ + { key: 'main', label: 'One', steps: [] }, + { key: 'main', label: 'Two', steps: [] }, + ], + }) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), /used by more than one phase/) +}) + +test('a key a later phase owns is refused, not silently preserved', () => { + const top = spec.validate({ schedule: { kind: 'manual' }, phases: [], announcements: [] }) + assert.equal(top.ok, false) + assert.match(top.errors.join('\n'), /unknown key "announcements"/) + + const phase = spec.validate({ + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m' } }], + }) + assert.equal(phase.ok, false) + assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/) +}) + +test('only the manual schedule exists in this phase', () => { + const weekly = spec.validate({ + schedule: { kind: 'weekly', days: ['fri'], time: '20:00' }, + phases: [{ key: 'main', label: 'Main', steps: [] }], + }) + assert.equal(weekly.ok, false) + assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/) +}) + +test('every problem is reported, not just the first', () => { + const result = spec.validate({ + schedule: { kind: 'manual' }, + phases: [ + { key: 'BAD KEY', label: '', steps: [{ actionId: 'demo.world.change', params: {} }] }, + ], + }) + assert.equal(result.ok, false) + const joined = result.errors.join('\n') + assert.match(joined, /bad phase key/) + assert.match(joined, /a phase needs a label/) + assert.match(joined, /"region" is required/) + assert.match(joined, /"count" is required/) +}) + +test('the size bounds hold', () => { + const many = { + schedule: { kind: 'manual' }, + phases: Array.from({ length: spec.MAX_PHASES + 1 }, (_, i) => ({ + key: `p${i}`, + label: `P${i}`, + steps: [], + })), + } + const result = spec.validate(many) + assert.equal(result.ok, false) + assert.match(result.errors.join('\n'), new RegExp(`at most ${spec.MAX_PHASES} phases`)) +}) diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js new file mode 100644 index 0000000..3f589a9 --- /dev/null +++ b/server/test/eventsAdmin.test.js @@ -0,0 +1,597 @@ +// ── The events admin surface (EVENTS.md § API surface, Phase 1) ──────────── +// +// `eventSpec.test.js` covers the spec validator and `eventActionRegistry.test.js` +// the registry; re-asserting either here would be a second copy of a test rather +// than a second test. What is genuinely new is what the SURFACE decides: +// +// • **publish snapshots.** It cuts an immutable version, points the definition +// at it, and a later edit does not touch the version a run would pin. +// • **publish re-validates against the registries as they stand now**, not +// against the save that wrote the spec. A module uninstalled in between must +// block the publish, because the alternative is a run that fails at dispatch +// with the world half-changed. +// • **an empty event does not publish.** It would run cleanly and do nothing, +// which reads as a broken run rather than an empty one. +// • **the slug is frozen after create**, because the public event page lives +// at it and a retitle must not break a posted link. +// • **archiving is refused while a run is in flight**, and there is no hard +// delete at all. +// • **creating an occurrence twice creates ONE run.** The unique index is what +// makes that true, so the second call answers `created: false` with the +// existing row rather than erroring. +// • **a created run stays `scheduled`.** There is no runner until Phase 2, and +// that has to be visible as the correct state rather than as a stall. +// +// The `.db` layer is stubbed in-memory and the real models and controllers run +// against it, the shape `engagementAdmin.test.js` uses. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const ctrl = require('../src/router/v1/admin/events.controller') +const definitionsDb = require('../src/model/events/eventDefinitions.db') +const versionsDb = require('../src/model/events/eventVersions.db') +const runsDb = require('../src/model/events/eventRuns.db') +const stepsDb = require('../src/model/events/eventRunSteps.db') +const logDb = require('../src/model/events/eventRunLog.db') +const seriesDb = require('../src/model/events/eventSeries.db') +const activity = require('../src/model/activity/activity.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +// ── In-memory stand-ins ──────────────────────────────────────────────────── + +let store +const originals = {} +for (const [name, mod] of [ + ['definitionsDb', definitionsDb], + ['versionsDb', versionsDb], + ['runsDb', runsDb], + ['stepsDb', stepsDb], + ['logDb', logDb], + ['seriesDb', seriesDb], + ['activity', activity], +]) { + originals[name] = { mod, fns: { ...mod } } +} +const restoreOriginals = () => { + for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) +} + +// The occurrence key, as a string, so the stub can enforce the UNIQUE index the +// real table enforces. Reproducing it is the point of several tests below: the +// index — not the claim — is what makes "one run per occurrence per scope" true. +const occurrenceKey = (definitionId, scope, when) => + `${definitionId}|${scope || ''}|${new Date(when).toISOString()}` + +function installStubs() { + store = { + definitions: new Map(), + versions: new Map(), + runs: new Map(), + steps: new Map(), + log: [], + series: new Map(), + occurrences: new Set(), + nextDefinition: 1, + nextVersion: 1, + nextRun: 1, + nextStep: 1, + } + + const shape = (d) => ({ + ...d, + series_name: store.series.get(d.series_id)?.name ?? null, + series_slug: store.series.get(d.series_id)?.slug ?? null, + current_version: store.versions.get(d.current_version_id)?.version ?? null, + }) + + definitionsDb.list = async ({ state = null } = {}) => + [...store.definitions.values()].filter((d) => !state || d.state === state).map(shape) + definitionsDb.getById = async (id) => + store.definitions.has(id) ? shape(store.definitions.get(id)) : undefined + definitionsDb.getBySlug = async (slug) => + [...store.definitions.values()].filter((d) => d.slug === slug).map(shape)[0] + definitionsDb.slugTaken = async (slug, exceptId = null) => + [...store.definitions.values()].some((d) => d.slug === slug && d.id !== exceptId) + definitionsDb.insert = async (d) => { + const id = store.nextDefinition++ + store.definitions.set(id, { + id, + state: 'draft', + current_version_id: null, + created_at: new Date(), + updated_at: new Date(), + ...d, + }) + return id + } + definitionsDb.update = async (id, d) => { + const existing = store.definitions.get(id) + if (existing) Object.assign(existing, d, { updated_at: new Date() }) + } + definitionsDb.markReady = async (id, versionId, userId) => { + const existing = store.definitions.get(id) + if (existing) { + Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId }) + } + } + definitionsDb.archive = async (id, userId) => { + const existing = store.definitions.get(id) + if (existing) Object.assign(existing, { state: 'archived', updated_by: userId }) + } + + versionsDb.listForDefinition = async (definitionId) => + [...store.versions.values()] + .filter((v) => v.definition_id === definitionId) + .sort((a, b) => b.version - a.version) + versionsDb.getById = async (id) => store.versions.get(id) || undefined + versionsDb.nextVersion = async (definitionId) => + [...store.versions.values()].filter((v) => v.definition_id === definitionId).length + 1 + versionsDb.insert = async (definitionId, version, spec, userId) => { + const id = store.nextVersion++ + // Deep-copied on the way in, because the whole point of a version is that a + // later edit of the working spec cannot reach it. A stub that stored the + // reference would make the snapshot test pass for the wrong reason. + store.versions.set(id, { + id, + definition_id: definitionId, + version, + spec: JSON.parse(JSON.stringify(spec)), + published_at: new Date(), + published_by: userId, + }) + return id + } + + const shapeRun = (r) => ({ + ...r, + definition_title: store.definitions.get(r.definition_id)?.title ?? null, + definition_slug: store.definitions.get(r.definition_id)?.slug ?? null, + version_number: store.versions.get(r.version_id)?.version ?? null, + }) + + runsDb.list = async ({ definitionId = null, status = null } = {}) => + [...store.runs.values()] + .filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status)) + .map(shapeRun) + runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined) + runsDb.materialise = async (run) => { + const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for) + if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job + store.occurrences.add(key) + const id = store.nextRun++ + store.runs.set(id, { + id, + status: 'scheduled', + health: 'ok', + cleanup_status: 'not_required', + current_phase: null, + started_at: null, + ended_at: null, + last_error: null, + created_at: new Date(), + ...run, + scheduled_for: new Date(run.scheduled_for), + rehearsal: Boolean(run.rehearsal), + }) + return id + } + runsDb.findOccurrence = async (definitionId, scope, when) => + [...store.runs.values()] + .filter( + (r) => occurrenceKey(r.definition_id, r.scope, r.scheduled_for) === occurrenceKey(definitionId, scope, when), + ) + .map(shapeRun)[0] + runsDb.countActiveForDefinition = async (definitionId) => + [...store.runs.values()].filter( + (r) => + r.definition_id === definitionId && + ['scheduled', 'starting', 'running', 'paused', 'ending'].includes(r.status), + ).length + + stepsDb.listForRun = async (runId) => + [...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq) + stepsDb.getById = async (id) => store.steps.get(id) || undefined + stepsDb.materialisePhase = async (runId, phase, steps) => { + steps.forEach((step, seq) => { + const taken = [...store.steps.values()].some( + (s) => s.run_id === runId && s.phase === phase && s.seq === seq, + ) + if (taken) return + const id = store.nextStep++ + store.steps.set(id, { + id, + run_id: runId, + phase, + seq, + action_id: step.actionId, + params: step.params || {}, + action_version: step.actionVersion || 1, + status: 'pending', + due_at: null, + attempts: 0, + on_failure: step.onFailure || 'pause', + // The real materialiser stamps this from the row's own id, so the stub + // does too: a key that varied by attempt would defeat the whole retry + // story, and a stub that faked it would hide that. + idempotency_key: stepsDb.idempotencyKey(runId, id), + last_error: null, + started_at: null, + finished_at: null, + }) + }) + return stepsDb.listForRun(runId) + } + stepsDb.statusCounts = async (runId) => { + const counts = {} + for (const s of store.steps.values()) { + if (s.run_id === runId) counts[s.status] = (counts[s.status] || 0) + 1 + } + return counts + } + + logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse() + logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => { + store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() }) + return true + } + + seriesDb.list = async () => [...store.series.values()] + seriesDb.getById = async (id) => store.series.get(id) || null + seriesDb.exists = async (id) => store.series.has(id) + + // The audit log is a side effect, not a subject: it writes to a real table and + // never throws into the request path, so the stub records and stays quiet. + activity.log = async (entry) => { + store.log.push({ audit: true, ...entry }) + return true + } +} + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +beforeEach(() => { + registries._reset() + registries.registerCore() + installStubs() +}) +afterEach(() => { + registries._reset() + restoreOriginals() +}) + +/** A module whose one action a definition can be built around. */ +function registerDemoModule() { + const api = registries.stage('demo') + api.registerEventActions([ + { + id: 'demo.world.change', + label: 'Change the world', + risk: 'change', + reversible: 'none', + params: [{ name: 'region', type: 'string', required: true, example: 'Yew' }], + perform: async () => ({ ok: true }), + }, + ]) + registries.apply(api.staged) +} + +const announceStep = (body = 'The gates open at dusk.') => ({ + actionId: 'core.announce', + params: { leg: 'discord', body }, +}) + +const draftBody = (over = {}) => ({ + title: 'The Siege of Cove', + summary: 'An invasion, in three phases.', + timezone: 'Europe/Berlin', + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }], + }, + ...over, +}) + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + } +} + +async function call(handler, req) { + const res = mockRes() + let thrown = null + await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => { + thrown = err + }) + if (thrown) throw thrown + return res +} + +const createDraft = async (over = {}) => call(ctrl.create, { body: draftBody(over) }) + +// ── The catalog ──────────────────────────────────────────────────────────── + +test('the catalog serves the registry, callables stripped, with its vocabularies', async () => { + const res = await call(ctrl.catalog, {}) + assert.equal(res.statusCode, 200) + assert.deepEqual( + res.body.actions.map((a) => a.id), + ['core.announce', 'core.wait', 'core.cue'], + ) + for (const action of res.body.actions) assert.equal(action.perform, undefined) + assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible']) + assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run']) + // Phase 1 is honest about what it does not have: budget dimensions arrive with + // the module contract, so the catalog does not pretend to carry any. + assert.equal(res.body.budgets, undefined) +}) + +// ── Create, edit, slug ───────────────────────────────────────────────────── + +test('a draft is created with a derived slug and no version', async () => { + const res = await createDraft() + assert.equal(res.statusCode, 201) + assert.equal(res.body.event.state, 'draft') + assert.equal(res.body.event.slug, 'the-siege-of-cove') + assert.equal(res.body.event.currentVersionId, null) + assert.equal(res.body.event.timezone, 'Europe/Berlin') +}) + +test('the slug is frozen after create — a retitle does not move the public page', async () => { + const created = await createDraft() + const id = created.body.event.id + const res = await call(ctrl.update, { + params: { id: String(id) }, + body: draftBody({ title: 'The Second Siege of Cove' }), + }) + assert.equal(res.statusCode, 200) + assert.equal(res.body.event.title, 'The Second Siege of Cove') + assert.equal(res.body.event.slug, 'the-siege-of-cove') +}) + +test('a bad timezone and a bad grace window are refused with both problems named', async () => { + const res = await createDraft({ timezone: 'Middle/Earth', graceSeconds: 5 }) + assert.equal(res.statusCode, 400) + const joined = res.body.errors.join('\n') + assert.match(joined, /not an IANA zone name/) + assert.match(joined, /graceSeconds must be an integer/) +}) + +test('a step naming an unregistered action is refused at create', async () => { + const res = await createDraft({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'ghost.verb.do' }] }], + }, + }) + assert.equal(res.statusCode, 400) + assert.match(res.body.errors.join('\n'), /no module registers "ghost\.verb\.do"/) +}) + +// ── Publish ──────────────────────────────────────────────────────────────── + +test('publish snapshots the spec, and a later edit does not touch the version', async () => { + const created = await createDraft() + const id = created.body.event.id + + const published = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(published.statusCode, 200) + assert.equal(published.body.version, 1) + assert.equal(published.body.event.state, 'ready') + + // Edit the working copy afterwards. + await call(ctrl.update, { + params: { id: String(id) }, + body: draftBody({ + spec: { + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [announceStep('Something else entirely.')] }], + }, + }), + }) + + const version = await versionsDb.getById(published.body.versionId) + assert.equal(version.spec.phases[0].steps[0].params.body, 'The gates open at dusk.') + + // …and publishing again cuts version 2 rather than mutating version 1. + const again = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(again.body.version, 2) + const versions = await call(ctrl.listVersions, { params: { id: String(id) } }) + assert.deepEqual(versions.body.versions.map((v) => v.version), [2, 1]) + assert.deepEqual(versions.body.versions.map((v) => v.current), [true, false]) +}) + +test('publish is refused when a step went dormant after the save that wrote it', async () => { + registerDemoModule() + const created = await createDraft({ + spec: { + schedule: { kind: 'manual' }, + phases: [ + { key: 'main', label: 'Main', steps: [{ actionId: 'demo.world.change', params: { region: 'Yew' } }] }, + ], + }, + }) + const id = created.body.event.id + + // The module is uninstalled between the save and the publish. + registries._reset() + registries.registerCore() + + const res = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(res.statusCode, 409) + assert.match(res.body.errors.join('\n'), /no module registers demo\.world\.change/) + + // …and the definition is still editable, which is the other half of the rule: + // an uninstall must not be destructive after the fact. + const saved = await call(ctrl.update, { params: { id: String(id) }, body: { title: 'Renamed' } }) + assert.equal(saved.statusCode, 200) + assert.equal(saved.body.event.spec.phases[0].steps[0].dormant, true) +}) + +test('an event with no steps does not publish', async () => { + const created = await createDraft({ + spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] }, + }) + const res = await call(ctrl.publish, { params: { id: String(created.body.event.id) } }) + assert.equal(res.statusCode, 400) + assert.match(res.body.errors.join('\n'), /no phase has any steps/) +}) + +// ── Runs ─────────────────────────────────────────────────────────────────── + +test('a draft has nothing to run', async () => { + const created = await createDraft() + const res = await call(ctrl.startRun, { params: { id: String(created.body.event.id) } }) + assert.equal(res.statusCode, 409) + assert.match(res.body.errors.join('\n'), /no published version to run/) +}) + +test('a created run stays scheduled, with its first phase materialised', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + + const started = await call(ctrl.startRun, { params: { id: String(id) } }) + assert.equal(started.statusCode, 201) + assert.equal(started.body.created, true) + // The correct state for this phase, and it has to be visible as such rather + // than looking like a stall: there is no runner until Phase 2. + assert.equal(started.body.run.status, 'scheduled') + + const detail = await call(ctrl.getRun, { params: { runId: String(started.body.run.id) } }) + assert.equal(detail.body.steps.length, 1) + assert.equal(detail.body.steps[0].actionId, 'core.announce') + assert.equal(detail.body.steps[0].status, 'pending') + assert.deepEqual(detail.body.counts, { pending: 1 }) + // Minted at materialisation, 40 hex, and a function of identity alone. + assert.match(detail.body.steps[0].idempotencyKey, /^[0-9a-f]{40}$/) +}) + +test('one occurrence, asked for twice, is one run', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + + const when = '2026-10-31T20:00:00.000Z' + const first = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } }) + const second = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } }) + + assert.equal(first.statusCode, 201) + assert.equal(first.body.created, true) + // Not an error — the unique index doing exactly what it is for. The existing + // row is the answer. + assert.equal(second.statusCode, 200) + assert.equal(second.body.created, false) + assert.equal(second.body.run.id, first.body.run.id) + + const runs = await call(ctrl.listRuns, { query: { definitionId: String(id) } }) + assert.equal(runs.body.runs.length, 1) +}) + +test('the same instant in two scopes is two runs', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + + const when = '2026-10-31T20:00:00.000Z' + const a = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'europa' } }) + const b = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'atlantic' } }) + assert.equal(a.body.created, true) + assert.equal(b.body.created, true) + assert.notEqual(a.body.run.id, b.body.run.id) +}) + +test('the concurrency key is rendered from the run params', async () => { + const created = await createDraft({ concurrencyKey: 'invasion:{region}' }) + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const res = await call(ctrl.startRun, { + params: { id: String(id) }, + body: { params: { region: 'Yew' } }, + }) + assert.equal(res.body.run.concurrencyKey, 'invasion:Yew') +}) + +test('an unrendered placeholder is left standing rather than emptied', async () => { + // `invasion:` would collide with every other unrendered key on the deployment, + // which is the opposite of what a concurrency key is for. + const created = await createDraft({ concurrencyKey: 'invasion:{region}' }) + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const res = await call(ctrl.startRun, { params: { id: String(id) } }) + assert.equal(res.body.run.concurrencyKey, 'invasion:{region}') +}) + +test('the run log records the creation and the phase entry', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const started = await call(ctrl.startRun, { params: { id: String(id) } }) + + const res = await call(ctrl.getRunLog, { params: { runId: String(started.body.run.id) } }) + const kinds = res.body.log.map((l) => l.kind) + assert.ok(kinds.includes('run.created')) + assert.ok(kinds.includes('phase.entered')) +}) + +test('an unknown log kind is refused rather than stored', async () => { + // The closed set is enforced in the db layer, not by an ENUM, because it grows + // with almost every later phase — so it has to actually refuse. + restoreOriginals() + const ok = await logDb.write({ runId: 1, kind: 'not.a.kind' }) + assert.equal(ok, false) + installStubs() +}) + +// ── Archive ──────────────────────────────────────────────────────────────── + +test('archiving is refused while a run is in flight, and allowed once it is not', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.publish, { params: { id: String(id) } }) + const started = await call(ctrl.startRun, { params: { id: String(id) } }) + + const refused = await call(ctrl.archive, { params: { id: String(id) } }) + assert.equal(refused.statusCode, 409) + assert.match(refused.body.errors.join('\n'), /still in flight/) + + store.runs.get(started.body.run.id).status = 'completed' + + const res = await call(ctrl.archive, { params: { id: String(id) } }) + assert.equal(res.statusCode, 200) + assert.equal(res.body.event.state, 'archived') +}) + +test('an archived definition can be neither edited nor published', async () => { + const created = await createDraft() + const id = created.body.event.id + await call(ctrl.archive, { params: { id: String(id) } }) + + const edited = await call(ctrl.update, { params: { id: String(id) }, body: draftBody() }) + assert.equal(edited.statusCode, 409) + const published = await call(ctrl.publish, { params: { id: String(id) } }) + assert.equal(published.statusCode, 409) +}) + +test('the list filters by state, and an unknown id is 404 rather than 500', async () => { + await createDraft() + const second = await createDraft({ title: 'A Second Event' }) + await call(ctrl.publish, { params: { id: String(second.body.event.id) } }) + + const ready = await call(ctrl.list, { query: { state: 'ready' } }) + assert.deepEqual(ready.body.events.map((e) => e.title), ['A Second Event']) + + const missing = await call(ctrl.get, { params: { id: '9999' } }) + assert.equal(missing.statusCode, 404) + const bad = await call(ctrl.get, { params: { id: 'not-a-number' } }) + assert.equal(bad.statusCode, 400) +}) -- 2.49.1 From 2e964cfeee14e7972b3ece8818c601fb35de44f0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 06:32:24 -0500 Subject: [PATCH 02/18] feat(events): the runner (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `utils/eventRunner.js`, the eighth poller, wired into server.js beside engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their grace window into `missed`, advances each due run through its phases, and drains that phase's steps in `seq` order. The three core actions from Phase 1 get real bodies, so a published event started from the existing run route now announces, waits and completes on its own. No routes are added: a runner has no surface, and the live controls stay Phase 3's. Four things the org lead settled (2026-09-02): a parked step is `running` with a NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members rather than special cases keyed on an action id; a run whose concurrency key is held stays `scheduled` and lets its grace window decide; and `n` in §L's `retry(n)` is a runner constant. Co-Authored-By: Claude --- server/src/config/coreEventActions.js | 117 +++- server/src/events/dispatch.js | 166 +++++ server/src/model/events/eventRunLog.db.js | 41 +- server/src/model/events/eventRunSteps.db.js | 199 +++++- server/src/model/events/eventRuns.db.js | 260 +++++++- server/src/server.js | 7 + server/src/utils/eventRunner.js | 510 +++++++++++++++ server/test/eventActionRegistry.test.js | 71 +- server/test/eventRunner.test.js | 689 ++++++++++++++++++++ server/test/eventRunnerSql.test.js | 508 +++++++++++++++ 10 files changed, 2527 insertions(+), 41 deletions(-) create mode 100644 server/src/events/dispatch.js create mode 100644 server/src/utils/eventRunner.js create mode 100644 server/test/eventRunner.test.js create mode 100644 server/test/eventRunnerSql.test.js diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js index d26eb21..cb6adc0 100644 --- a/server/src/config/coreEventActions.js +++ b/server/src/config/coreEventActions.js @@ -13,28 +13,20 @@ // a human to go and do something. A deployment with no game module installed has // a working event system made of exactly these. // -// **Nothing here dispatches yet.** Phase 1 builds the registry, the id grammar, -// the risk classes and the param validation; Phase 2 builds `utils/eventRunner.js` -// and is what calls `perform()`. The bodies below therefore answer with the -// envelope §F defines for a refusal — and specifically NOT with `{ ok: true }`, -// which is the one wrong answer a placeholder can give: `ok: true` on an action -// that did nothing is a recorded world change that did not occur, which is the -// exact mistake the envelope's failure default exists to prevent. `retry: false` -// because a missing runner is not a transient condition. +// **Phase 2 gave all three real bodies**, and between them they exercise every +// shape §F's envelope can take: `core.announce` does work and finishes, +// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds +// without finishing at all. The runner learns nothing about any of them by id — +// each says what it needs in the envelope, through the same two members Phase 7 +// hands to a module. // // **This file must not touch the database.** It is required from `registerCore()`, // which runs under `routeManifest.js` and `swagger.js` against a dead pool -// (MODULE_API.md §2.2). It is pure data plus three functions that are not called. +// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is +// looked up inside `perform()`, per call, which is also what makes a leg +// registered by a module that booted later reachable at all. -// A factory rather than one shared function, because `perform`'s argument is -// §F's dispatch envelope — `{ runId, stepId, idempotencyKey, scope, params, -// actor, verify }` — and it does not carry the action's own id. Closing over it -// is what lets the refusal name which action refused. -const notWiredYet = (actionId) => async () => ({ - ok: false, - retry: false, - error: `${actionId} is declared in Phase 1 and dispatched from Phase 2`, -}) +const registries = require('../modules/registries') const ACTIONS = [ { @@ -81,7 +73,52 @@ const ACTIONS = [ }, ], - perform: notWiredYet('core.announce'), + /** + * Publish through the announce leg the step names. + * + * **The legs are reused rather than reimplemented** (§J, "reuse the legs"): + * `discord` is core's and `towncrier` is module-uo's, both already registered, + * both already carrying a `classify()` that knows what their transport's + * failures mean. An event announcement that went out by some other path would + * be a second delivery mechanism with its own bugs. + * + * A leg's `dispatch()` takes a POST — that is the shape the news path gave it + * — so an event announcement is presented as one. `excerpt` is the body + * because it is the field every leg renders as prose, and `image_url` is null + * because an event announcement has no article behind it to illustrate. + * Widening the leg contract to carry a second payload shape is a + * MODULE_API change, and Phase 7 is where those are made. + * + * The leg id is checked HERE rather than at authoring time, and that is not + * laxness: legs are registered by modules, and a spec is validated in a + * process that may have booted before the module that owns the leg. + */ + async perform({ params, verify }) { + const registered = registries.announceLeg(params.leg) + if (!registered) { + // Terminal, not transient. A leg nobody registers will not appear + // between two attempts sixty seconds apart, and the honest cause — a + // module removed, or a typo the authoring form could not catch — is a + // thing a human fixes. + return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` } + } + // A dry run reports what it WOULD do and sends nothing (§I). Answering + // before the dispatch rather than inside the leg is what keeps that true + // for legs written by people who never read this file. + if (verify) return { ok: true } + + const result = await registered.dispatch({ + title: params.title || null, + excerpt: params.body, + image_url: null, + }) + // The leg's own classification, not a second opinion. `retry` vs + // `terminal` for a Discord webhook is a judgement `discordAnnounce.classify` + // already makes, and making it twice is how the two drift. + const { outcome, error } = registered.classify(result) + if (outcome === 'done') return { ok: true } + return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` } + }, }, { @@ -105,11 +142,18 @@ const ACTIONS = [ }, ], - // A wait is a genuine no-op at dispatch, and it will stay one: the delay is - // the NEXT step's `due_at`, which the runner owns, not something this - // function sleeps through. A `perform` that slept would hold a step's claim - // for the duration and turn a five-minute pause into a five-minute lease. - perform: notWiredYet('core.wait'), + // A wait is a genuine no-op at dispatch, and it stayed one: the delay is the + // NEXT step's `due_at`, which the runner owns, not something this function + // sleeps through. A `perform` that slept would hold a step's claim for the + // duration and turn a five-minute pause into a five-minute lease — and the + // reclaim would then re-dispatch it, so a long enough wait would never end. + // + // `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is + // why the runner can honour this without knowing what `core.wait` is. + async perform({ params, verify }) { + if (verify) return { ok: true } + return { ok: true, holdFor: params.seconds } + }, }, { @@ -142,11 +186,26 @@ const ACTIONS = [ }, ], - // Phase 2 gives this its parking semantics — a cue step does not complete - // when `perform` answers, it completes when a human presses confirm, and the - // control that does so is Phase 3's. Both of those are what make this the - // one action whose runtime shape is deliberately not decided here. - perform: notWiredYet('core.cue'), + /** + * Post the instruction and PARK. The step does not complete here. + * + * `await: 'human'` is the envelope member that says so (org lead, + * 2026-09-02), and the runner's answer to it is to leave the step `running` + * with a NULL lease — genuinely in flight, nothing holding it, so the stale + * reclaim passes it by and a cue posted on Friday is still waiting on Monday. + * The step ends when someone presses confirm, which is Phase 3's control. + * + * **Nothing is delivered from here in Phase 2, and that is visible rather + * than pretended.** The instruction is carried by the step's own params and + * shown on the run console; routing it to Discord or to a staff inbox is + * Phase 10's integration work, through the engagement triggers that own every + * other notification on this platform. An action that grew its own delivery + * path would be the second one. + */ + async perform({ verify }) { + if (verify) return { ok: true } + return { ok: true, await: 'human' } + }, }, ] diff --git a/server/src/events/dispatch.js b/server/src/events/dispatch.js new file mode 100644 index 0000000..3ac9e32 --- /dev/null +++ b/server/src/events/dispatch.js @@ -0,0 +1,166 @@ +// ── Dispatching one step to one action ───────────────────────────────────── +// +// EVENTS.md §F. This is the boundary between the runner and code core did not +// write, and it exists as its own file because it has exactly one job: call +// `perform()` and turn whatever comes back — an envelope, a lie, a throw, a +// promise that never settles — into one of four classifications the runner knows +// how to act on. +// +// **§F's load-bearing rule, and the reason none of this is inlined into the +// runner: no shape a failure can take may read as success.** A rejected promise, +// a throw, a timeout, a non-object and a missing `ok` are all +// `{ ok: false, retry: true }`. That is the inverse of `registerTeamProvider`'s +// default, deliberately — a team provider that refuses leaves core showing what +// it already had, because staleness is cheap, whereas an action that half-ran and +// was recorded as `done` is a world change nothing will ever come back for. +// +// **The timeout is the module contract's, not this file's opinion.** Every action +// declares `budgetMs` at registration and the registry bounds it there; here it +// is enforced. Without it a module whose `perform()` awaits a socket that never +// answers holds a step's claim until the lease expires, and the reclaim then +// re-dispatches it — which is how one wedged sidecar becomes an infinite loop +// rather than a failed step. + +const registries = require('../modules/registries') +const log = require('../utils/logger')('events') + +// What a classification can be. `parked` is Phase 2's addition and it is the one +// outcome that is neither terminal nor a retry: the action succeeded, and the +// step is not finished, because something outside this system has to happen next. +const OUTCOMES = ['done', 'parked', 'retry', 'terminal'] + +// The upper bound on `holdFor`, in seconds. A wait is a scheduling instruction, +// not a lease, so this is generous — but it is bounded, because an action that +// answers `holdFor: 1e9` would park the phase past the heat death of the shard +// and the step that did it would look, in the console, exactly like one that +// worked. +const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60 + +/** + * Run `fn()` under a deadline. + * + * The loser of the race is not cancelled — JavaScript has no such thing, and a + * `perform()` still awaiting a socket keeps awaiting it. What the deadline buys + * is that the RUNNER stops waiting, which is the half that matters: the step is + * classified, the claim is released, and the tick moves on. A late answer from + * the abandoned call lands on a step that has already been written, and the + * idempotency key is what makes the retry that follows safe on the game side. + */ +function withDeadline(fn, ms, actionId) { + let timer = null + const deadline = new Promise((resolve) => { + timer = setTimeout( + () => resolve({ __timedOut: true, error: `${actionId} exceeded its ${ms}ms budget` }), + ms, + ) + if (timer.unref) timer.unref() + }) + return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => { + if (timer) clearTimeout(timer) + }) +} + +/** + * Turn a raw `perform()` answer into `{ outcome, error?, holdSeconds?, resources? }`. + * + * Exported and pure, so the classification rules are testable without a registry, + * a database or a clock — which matters because they are the rules that decide + * whether a world change is recorded as having happened. + */ +function classify(result, actionId) { + if (result && result.__timedOut) { + // Transient by default: a timeout says nothing about whether the action ran. + // That ambiguity is exactly what the idempotency key exists to resolve, and + // resolving it on the game side is Phase 11's protocol work — until then a + // retry is the honest choice and the risk class decides what happens when the + // retries run out. + return { outcome: 'retry', error: result.error } + } + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return { outcome: 'retry', error: `${actionId} answered with no envelope` } + } + if (result.ok !== true) { + // `retry` must be opted into. An action that means "this will never work" + // says `retry: false`, and an envelope that forgot to say anything gets the + // benefit of the doubt on the transient question but not on the success one. + const retry = result.retry !== false + return { + outcome: retry ? 'retry' : 'terminal', + error: result.error ? String(result.error) : `${actionId} refused`, + } + } + + // ── The two success shapes that are not "finished" ── + // + // Both were settled by the org lead on 2026-09-02, and both are envelope + // members rather than special cases keyed on an action id, so that the runner + // never names a verb. `core.cue` and `core.wait` reach them through the same + // door Phase 7 opens to a module's own long-running action. + if (result.await === 'human') { + return { outcome: 'parked', error: null, resources: result.resources || [] } + } + + let holdSeconds = 0 + if (result.holdFor !== undefined && result.holdFor !== null) { + const n = Number(result.holdFor) + if (!Number.isFinite(n) || n < 0) { + return { outcome: 'terminal', error: `${actionId} answered a bad holdFor "${result.holdFor}"` } + } + holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS) + } + + return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [] } +} + +/** + * Dispatch one step. Never throws. + * + * `verify` rides through to `perform()` unchanged (§I's dry run, Phase 6's + * route): `verify === true` means validate and report, change nothing. It is + * passed from here rather than being a separate code path so that the dry run + * exercises the real dispatcher — a dry run down a second path is a dry run of + * the second path. + */ +async function dispatchStep(step, { run, actor = null, verify = false } = {}) { + const action = registries.eventAction(step.action_id) + if (!action) { + // §L, verbatim: "a step naming one fails terminal with the module named, and + // the run degrades rather than claiming success. Never a silent skip." The + // module was uninstalled or failed to boot between publish and now — publish + // refuses a dormant step, so this cannot be an authoring mistake. + return { outcome: 'terminal', error: `no module registers "${step.action_id}"`, dormant: true } + } + + const envelope = { + runId: run.id, + stepId: step.id, + idempotencyKey: step.idempotency_key, + scope: run.scope || '', + params: step.params || {}, + actor, + verify: Boolean(verify), + } + + let raw + try { + raw = await withDeadline(() => action.perform(envelope), action.budgetMs, action.id) + } catch (err) { + // A module should not throw, and if one does it is a transient failure rather + // than a crashed tick — announceWorker's posture with its legs, and the + // reason one bad module cannot stop every other run on the deployment. + log.warn('event action threw', { action: action.id, run: run.id, step: step.id, message: err.message }) + return { outcome: 'retry', error: err.message } + } + + const classification = classify(raw, action.id) + if (step.action_version && action.version !== step.action_version) { + // Not a refusal: the step was authored against an older declaration and the + // module has moved on. The editor is where that becomes a warning (§F); here + // it is recorded, so a run that behaved oddly can be explained afterwards by + // reading the log rather than by guessing. + classification.actionVersionDrift = { authored: step.action_version, registered: action.version } + } + return classification +} + +module.exports = { dispatchStep, classify, withDeadline, OUTCOMES, MAX_HOLD_SECONDS } diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js index a32732c..8f34d87 100644 --- a/server/src/model/events/eventRunLog.db.js +++ b/server/src/model/events/eventRunLog.db.js @@ -23,6 +23,16 @@ const KINDS = [ 'phase.entered', // a phase's steps were materialised 'step.status', // a step transition, with the module's answer 'note', // a human action taken from the admin surface + // Phase 2's, all five of them answers to a question an operator asks out + // loud. `run.blocked` in particular is the whole reason this table exists + // rather than a server log line: "it did not start because run 37 holds + // invasion:Yew" is a fact with two run ids in it, and it has to be + // queryable from the run that did NOT start. + 'run.blocked', // an occurrence held off: another run has its concurrency key + 'run.health', // a health change, which is not a status change + 'step.retry', // a step failed transiently and will be attempted again + 'step.parked', // a step is waiting on a human and nothing is holding it + 'phase.completed', // every step of a phase reached a terminal status ] const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) } @@ -64,4 +74,33 @@ async function write({ runId, stepId = null, kind, phase = null, detail = null } } } -module.exports = { KINDS, listForRun, write } +/** + * Delete log lines belonging to runs that are both TERMINAL and older than + * `before`, a bounded number at a time. + * + * The schema comment beside `idx_evlog_at` parked this sweep here, and it is the + * rule Engagement Phase 14 arrived at applied to a second high-cardinality table: + * **only terminal rows are eligible.** A run still in flight keeps every line it + * has, however old — the log's whole job is answering "why didn't phase 3 start?" + * about a run that is, right now, not starting phase 3, and a horizon that could + * reach a live run would delete the answer while the question was still open. + * + * `LIMIT` makes one call a bounded amount of work rather than a table-sized + * transaction; the timer runs again and takes the next slice. The join is on the + * run's terminal status rather than on a precomputed id list so that a run which + * reached a terminal state between the two would not be missed. + */ +const pruneTerminal = async (before, limit = 5000) => { + const n = Math.min(Math.max(Number(limit) || 5000, 1), 50_000) + const result = await query( + `DELETE l FROM event_run_log l + JOIN event_runs r ON r.id = l.run_id + WHERE r.status IN ('completed','cancelled','failed','missed') + AND COALESCE(r.ended_at, r.updated_at) < ? + LIMIT ${n}`, + [before], + ) + return Number(result?.affectedRows || 0) +} + +module.exports = { KINDS, listForRun, write, pruneTerminal } diff --git a/server/src/model/events/eventRunSteps.db.js b/server/src/model/events/eventRunSteps.db.js index bf0a7dc..d9d4041 100644 --- a/server/src/model/events/eventRunSteps.db.js +++ b/server/src/model/events/eventRunSteps.db.js @@ -92,4 +92,201 @@ const statusCounts = async (runId) => { return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)])) } -module.exports = { listForRun, getById, materialisePhase, statusCounts, idempotencyKey } +// ── Phase 2: draining a step ─────────────────────────────────────────────── +// +// The CAS claim, the lease, the attempt counter and the terminal writes. Phase 1 +// left all of it out rather than stubbing it, and this is where it lands. +// +// **Two rules govern everything below, and both were paid for once already.** +// +// 1. `attempts` is incremented by the CLAIM and by nothing else, and no recovery +// path ever resets it. Engagement Phase 14's defect was a stale-row sweep that +// returned rows to their start state: the attempt ceiling became unreachable, +// so the row cycled forever, never reached a terminal status, and was +// therefore never eligible for any retention sweep. +// 2. A PARKED step is `running` with a NULL lease, and the reclaim only ever +// touches a lease that is non-NULL and expired (the org lead's answer, +// 2026-09-02). That is what lets a GM cue wait for a human overnight without a +// sweep re-dispatching the instruction every fifteen minutes. + +// A run's steps in authored order, for the phase the run is currently in. +const listForPhase = async (runId, phase) => + ( + await query( + 'SELECT * FROM event_run_steps WHERE run_id = ? AND phase = ? ORDER BY seq, id', + [runId, phase], + ) + ).map(hydrate) + +/** + * The next step of a phase that the runner may work on, or null. + * + * **Steps within a phase are strictly serial.** This returns the lowest-`seq` + * step that is not terminal, and the runner does nothing with step N+1 until N + * has finished — which is the only reading under which `core.wait` means anything + * at all, and the only one under which a cue can gate what follows it. + * + * A parked or running step is returned too, so the caller can see that the phase + * is occupied rather than concluding it is finished. + */ +const nextOpenStep = async (runId, phase) => { + const [row] = await query( + `SELECT * FROM event_run_steps + WHERE run_id = ? AND phase = ? + AND status IN ('pending','running') + ORDER BY seq, id LIMIT 1`, + [runId, phase], + ) + return hydrate(row) || null +} + +/** + * Take ownership of one pending step: the CAS `pending -> running`, plus a lease. + * + * `due_at` is honoured here rather than in the caller's filter so that the whole + * decision — is it mine, is it due — is one statement the database arbitrates. A + * NULL `due_at` is due now, which is what materialisation writes for every step + * that is not sitting behind a `core.wait`. + */ +async function claim(id, owner, leaseUntil, now) { + const result = await query( + `UPDATE event_run_steps + SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?, + started_at = COALESCE(started_at, NOW()) + WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)`, + [owner, leaseUntil, id, now], + ) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * Park a claimed step: it stays `running`, and its lease goes NULL. + * + * This is the whole mechanism behind `core.cue`. The step is genuinely in flight + * — an instruction has been posted and nothing else in the phase may proceed — + * but no process is holding it, so the reclaim must not take it back. A NULL + * lease says exactly that, and `reclaimStale` below is written to agree. + */ +const park = (id, note) => + query( + `UPDATE event_run_steps SET claim_expires_at = NULL, last_error = ? + WHERE id = ? AND status = 'running'`, + [note ? String(note).slice(0, 500) : null, id], + ) + +/** + * Release a claimed step back to `pending` for a later attempt. + * + * `attempts` is untouched — it was already incremented by the claim, which is the + * only place that may. Backoff is flat rather than exponential for the reason the + * outbox's is: `due_at` is also the event's own clock, and a doubling backoff + * pushes a step arbitrarily far past the moment the event was about. + */ +const reschedule = (id, dueAt, error) => + query( + `UPDATE event_run_steps + SET status = 'pending', due_at = ?, claimed_by = NULL, claim_expires_at = NULL, last_error = ? + WHERE id = ? AND status = 'running'`, + [dueAt, error ? String(error).slice(0, 500) : null, id], + ) + +/** A terminal outcome for one step: done, failed, skipped, refused or cancelled. */ +const finish = (id, status, error) => + query( + `UPDATE event_run_steps + SET status = ?, last_error = ?, finished_at = NOW(), + claimed_by = NULL, claim_expires_at = NULL + WHERE id = ? AND status = 'running'`, + [status, error ? String(error).slice(0, 500) : null, id], + ) + +/** + * Delay the next not-yet-started step of a phase — what `core.wait` actually does. + * + * The wait step itself completes normally; the pause is the NEXT step's `due_at`, + * owned by the runner. A `perform()` that slept would hold its claim for the + * duration and turn a five-minute pause into a five-minute lease, which is the + * one shape this must not have. + * + * Guarded on `status = 'pending'` and on the current `due_at` being sooner, so a + * re-dispatch of a wait whose ack was lost cannot push the following step further + * out a second time. + */ +const holdNext = async (runId, phase, afterSeq, dueAt) => { + const result = await query( + `UPDATE event_run_steps + SET due_at = ? + WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending' + AND (due_at IS NULL OR due_at < ?) + ORDER BY seq LIMIT 1`, + [dueAt, runId, phase, afterSeq, dueAt], + ) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * Recover steps whose claim outlived the process that took it. + * + * **`attempts` is not reset and the lease being NULL is not staleness.** The + * first is Engagement Phase 14's rule; the second is what makes a parked cue + * survive. A step that has already burned its attempts leaves `running` as + * `failed` rather than being handed back, and in that order — a reclaim that ran + * first would return it to `pending` and it would be retried forever. + */ +const reclaimStale = async (now, maxAttempts = 0) => { + let failed = 0 + if (Number(maxAttempts) > 0) { + const gaveUp = await query( + `UPDATE event_run_steps + SET status = 'failed', last_error = 'gave up after repeated interruptions', + finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL + WHERE status = 'running' + AND claim_expires_at IS NOT NULL AND claim_expires_at < ? + AND attempts >= ?`, + [now, Math.floor(maxAttempts)], + ) + failed = Number(gaveUp?.affectedRows || 0) + } + const reclaimed = await query( + `UPDATE event_run_steps + SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL + WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?`, + [now], + ) + return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) } +} + +/** + * Cancel every step of a run that has not started (Phase 3's cancel, and the + * abort_run disposition). + * + * A `running` step is deliberately left alone, parked or not: nothing can recall + * a command already sent, and a second writer on that row would race the process + * that owns it (§L). + */ +const cancelPending = async (runId) => { + const result = await query( + `UPDATE event_run_steps + SET status = 'cancelled', finished_at = NOW() + WHERE run_id = ? AND status = 'pending'`, + [runId], + ) + return Number(result?.affectedRows || 0) +} + +module.exports = { + listForRun, + listForPhase, + getById, + materialisePhase, + statusCounts, + idempotencyKey, + nextOpenStep, + claim, + park, + reschedule, + finish, + holdNext, + reclaimStale, + cancelPending, +} diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js index bf8a59f..a9c06fe 100644 --- a/server/src/model/events/eventRuns.db.js +++ b/server/src/model/events/eventRuns.db.js @@ -17,6 +17,12 @@ const { parseJson } = require('./eventJson') const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) } +// The statuses a run never leaves. A transition INTO one of these stamps +// `ended_at` and drops the claim, and only rows in one of them are eligible for +// the log retention sweep -- Engagement Phase 14's rule, which is a bound at all +// only if every path a run can take reaches one of them. +const TERMINAL = ['completed', 'cancelled', 'failed', 'missed'] + const SELECT_LIST = ` SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number FROM event_runs r @@ -102,4 +108,256 @@ const countActiveForDefinition = async (definitionId) => { return Number(row?.n || 0) } -module.exports = { list, getById, materialise, findOccurrence, countActiveForDefinition } +// ── Phase 2: the claim, the transitions and the reclaim ──────────────────── +// +// Everything below is the runner's, and none of it existed in Phase 1 for a +// stated reason: a half-written claim is worse than no claim, because it reads +// as protection. It is written here now, in full. +// +// **The division of labour with the unique index has not changed.** The index one +// section up is what makes "one run per occurrence per scope" TRUE; the CAS below +// decides only WHO advances an occurrence that already exists. Neither substitutes +// for the other, and this deployment being single-instance (§N4) changes the test +// rather than the design — the same two protections are what keep a tick that +// overran into the next one from advancing a run twice. + +/** + * Runs the runner should look at this tick: due, and not yet terminal. + * + * It selects rather than claims — `claimStart` and `claimTick` below are one row + * at a time — so two sweepers see the same candidates and then disagree, + * harmlessly, about which of them owns each. `idx_evrun_due (status, + * scheduled_for)` is this query. + * + * `paused` is absent from the status list on purpose. A paused run is waiting on + * a human and must not be advanced by a tick; the only thing that moves it is + * Phase 3's resume control. + */ +const findDue = async (now, limit = 50) => { + const n = Math.min(Math.max(Number(limit) || 50, 1), 500) + return ( + await query( + `SELECT * FROM event_runs + WHERE status IN ('scheduled','starting','running','ending') + AND scheduled_for <= ? + ORDER BY scheduled_for, id + LIMIT ${n}`, + [now], + ) + ).map(hydrate) +} + +/** + * Take ownership of a run that has not started: the CAS `scheduled -> starting`. + * + * Verbatim the outbox claim the org lead settled over `SELECT ... FOR UPDATE + * SKIP LOCKED` — the instance the server reports `affectedRows = 1` to owns the + * row, every other sweeper gets 0 and moves on. No transaction to hold open and + * no MariaDB version floor. + */ +async function claimStart(id, owner, leaseUntil) { + const result = await query( + `UPDATE event_runs + SET status = 'starting', claimed_by = ?, claim_expires_at = ?, + started_at = COALESCE(started_at, NOW()) + WHERE id = ? AND status = 'scheduled'`, + [owner, leaseUntil, id], + ) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * Take a lease on a run already in flight, so one tick works on it at a time. + * + * Unlike `claimStart` this does not change `status` — the run is already + * `starting`, `running` or `ending`, and what is being claimed is the right to + * advance it. + * + * **A live lease is not re-enterable, not even by the process that took it**, and + * that is the whole point rather than an oversight. `setInterval` fires the next + * tick whether or not the last one has returned, so an owner-matches escape + * clause here would let one process advance one run twice at once — which is + * precisely the overrun the plan says the CAS is meant to protect against. A run + * this process still holds is a run this process is still working on; the tick + * skips it, and `releaseClaim` below is what ends that in the ordinary case. + */ +async function claimTick(id, owner, leaseUntil, now) { + const result = await query( + `UPDATE event_runs + SET claimed_by = ?, claim_expires_at = ? + WHERE id = ? + AND status IN ('starting','running','ending') + AND (claim_expires_at IS NULL OR claim_expires_at < ?)`, + [owner, leaseUntil, id, now], + ) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * Give a still-in-flight run back, so the next tick can pick it up at once. + * + * A run left parked on a GM cue, or waiting out a `core.wait`, is not finished + * and must not carry a lease: without this the run would be unadvanceable until + * the lease expired, which would turn every wait into `max(wait, leaseMs)`. + * Scoped to `claimed_by = ?` so a process can only release its own claim. + */ +async function releaseClaim(id, owner) { + const result = await query( + 'UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL WHERE id = ? AND claimed_by = ?', + [id, owner], + ) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * A guarded status transition: `from -> to`, and only from `from`. + * + * Every move the runner makes goes through here rather than through a bare + * UPDATE, so "did this transition actually happen" is answerable at each call + * site. A `false` is not an error — it is another worker, or this run having been + * cancelled from the admin surface between the read and the write, which is a + * race Phase 3's live controls make ordinary. + */ +async function transition(id, from, to, { phase, error, clearClaim = false } = {}) { + const sets = ['status = ?'] + const args = [to] + if (phase !== undefined) { + sets.push('current_phase = ?') + args.push(phase) + } + if (error !== undefined) { + sets.push('last_error = ?') + args.push(error === null ? null : String(error).slice(0, 500)) + } + if (TERMINAL.includes(to)) sets.push('ended_at = COALESCE(ended_at, NOW())') + if (clearClaim || TERMINAL.includes(to)) sets.push('claimed_by = NULL', 'claim_expires_at = NULL') + + const froms = Array.isArray(from) ? from : [from] + const result = await query( + `UPDATE event_runs SET ${sets.join(', ')} + WHERE id = ? AND status IN (${froms.map(() => '?').join(',')})`, + [...args, id, ...froms], + ) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * Set health without touching status (§E). + * + * The two columns are separate because a run can be genuinely running and + * degraded at once — announcements landing, world writes parked — and one column + * cannot say both. Guarded on the current value so a tick that re-observes the + * same degradation does not restamp `updated_at`. + */ +async function setHealth(id, health) { + const result = await query('UPDATE event_runs SET health = ? WHERE id = ? AND health <> ?', [ + health, + id, + health, + ]) + return Number(result?.affectedRows || 0) === 1 +} + +/** + * Runs whose start instant passed more than their own grace window ago (§E, §L). + * + * The window is per definition, so the comparison is against `grace_seconds` on + * the joined row rather than against a constant here: an event whose announcement + * gives a fifteen-minute window and one that must start on the second are the + * same query with different data. + * + * Only `scheduled` runs qualify. A run that reached `starting` has begun, and + * "began and then stalled" is a different fact from "never began" — conflating + * them would let `missed` describe a run that had already announced itself. + */ +const findMissed = async (now, limit = 100) => { + const n = Math.min(Math.max(Number(limit) || 100, 1), 500) + return ( + await query( + `SELECT r.* FROM event_runs r + JOIN event_definitions d ON d.id = r.definition_id + WHERE r.status = 'scheduled' + AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ? + ORDER BY r.scheduled_for + LIMIT ${n}`, + [now], + ) + ).map(hydrate) +} + +/** + * Is another run holding this concurrency key? + * + * The org lead's answer for a held key (2026-09-02) is to leave the run + * `scheduled` and let the grace window decide, so this is a READ rather than a + * claim: the caller holds off, logs which run holds the key, and tries again next + * tick. `idx_evrun_concurrency (concurrency_key, status)` is this query, and a + * NULL key is skipped by that index — which is right, because a definition with + * no key never contends. + */ +const concurrencyHolder = async (key, exceptRunId) => { + if (!key) return null + const [row] = await query( + `SELECT id, status, definition_id FROM event_runs + WHERE concurrency_key = ? + AND id <> ? + AND status IN ('starting','running','paused','ending') + ORDER BY id LIMIT 1`, + [key, exceptRunId], + ) + return row || null +} + +/** + * Recover runs whose claim outlived the process that took it. + * + * **It does not change status and it touches no counter.** All it releases is the + * lease; the run stays exactly where it was and the next tick picks it up through + * `findDue`. This is Engagement Phase 14's lesson applied one table over: a sweep + * that returned a stale row to its start state made the attempt ceiling + * unreachable, so the row cycled forever, never terminal, and therefore never + * eligible for any retention sweep. + */ +const reclaimStale = async (now) => { + const result = await query( + `UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL + WHERE status IN ('starting','running','ending') + AND claim_expires_at IS NOT NULL + AND claim_expires_at < ?`, + [now], + ) + return Number(result?.affectedRows || 0) +} + +/** Terminal runs that ended before `before` — what the log retention sweep walks. */ +const terminalBefore = async (before, limit = 500) => { + const n = Math.min(Math.max(Number(limit) || 500, 1), 5000) + return ( + await query( + `SELECT id FROM event_runs + WHERE status IN (${TERMINAL.map(() => '?').join(',')}) + AND COALESCE(ended_at, updated_at) < ? + ORDER BY id LIMIT ${n}`, + [...TERMINAL, before], + ) + ).map((r) => Number(r.id)) +} + +module.exports = { + list, + getById, + materialise, + findOccurrence, + countActiveForDefinition, + findDue, + findMissed, + claimStart, + claimTick, + releaseClaim, + transition, + setHealth, + concurrencyHolder, + reclaimStale, + terminalBefore, + TERMINAL, +} diff --git a/server/src/server.js b/server/src/server.js index 7ac2a23..be12168 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -15,6 +15,7 @@ const engagementRetentionPrune = require('./utils/engagementRetentionPrune') const teamForumUploadSweep = require('./utils/teamForumUploadSweep') const teamDigestWorker = require('./utils/teamDigestWorker') const engagementWorker = require('./utils/engagementWorker') +const eventRunner = require('./utils/eventRunner') const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') @@ -169,6 +170,11 @@ async function start() { // enables a rule: core seeds none and `enabled` defaults to 0. engagementWorker.start() + // Advance scheduled events (EVENTS.md §E). Materialise, advance, drain, and the + // run-log retention sweep. No-op until an admin publishes a definition and + // starts a run: core ships no event definitions. + eventRunner.start() + setupShutdown(server, internalServer) } @@ -192,6 +198,7 @@ function setupShutdown(server, internalServer) { teamForumUploadSweep.stop() // stop the forum upload sweep teamDigestWorker.stop() // stop the Team forum digest timer engagementWorker.stop() // stop the engagement outbox worker + eventRunner.stop() // stop the event runner server.close(() => log.info('http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed')) try { diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js new file mode 100644 index 0000000..e645496 --- /dev/null +++ b/server/src/utils/eventRunner.js @@ -0,0 +1,510 @@ +// ── The event runner ─────────────────────────────────────────────────────── +// +// EVENTS.md §E, and Phase 2 of EVENTS_PLAN.md. The eighth poller: same +// `setInterval` + `unref()` + `stop()` shape as `announceWorker`, the three Team +// sweepers and `engagementWorker`, wired into `server.js` beside them. In the +// **website** process rather than the bot, which cannot load module code. +// +// Its tick does four things, in this order: +// +// 1. **reclaim** — release leases whose holder died, never touching `attempts` +// 2. **materialise** — sweep occurrences past their grace window into `missed` +// 3. **advance** — claim each due run and move it through its phases +// 4. **prune** — the `event_run_log` retention sweep, on its own long clock +// +// **What "materialise" means in this phase.** §E's tick materialises due +// occurrences from a recurrence; the spec validator accepts `kind: 'manual'` +// alone until Phase 4, so there is no recurrence to expand and the only +// occurrences that exist are the ones an admin created. What that leaves for this +// leg is the half that is already real and already needed: the grace window. A +// run whose instant passed while the process was down does not start late and +// silently — it becomes `missed`, which is terminal and which a human can see +// (§L). Phase 4 adds the expansion above it. +// +// **Two properties this file must not lose**, both already paid for once on this +// codebase: +// +// - **A reclaim never resets `attempts`.** Engagement Phase 14's defect: a sweep +// that returned every stale row to its start state made the attempt ceiling +// unreachable, so the row cycled forever, never terminal, and therefore never +// eligible for any retention sweep. +// - **The unique index, not the claim, is what prevents a double run.** The claim +// decides *who* advances an occurrence; `uq_evrun_occurrence` is what stops two +// of them existing. Neither substitutes for the other. +// +// §N4 settled this deployment as single-instance, so the `--scale app=2` rig the +// engagement workstream used is deliberately not built. Every claim path is here +// exactly as §E specifies anyway, because the multi-instance case is not what +// they are for on a single container: the CAS is what protects a tick that +// overran into the next one, and the lease and its reclaim are what recover a +// step whose process died mid-dispatch. Both happen with one app. + +const os = require('os') + +const runsDb = require('../model/events/eventRuns.db') +const stepsDb = require('../model/events/eventRunSteps.db') +const logDb = require('../model/events/eventRunLog.db') +const versionsDb = require('../model/events/eventVersions.db') +const registries = require('../modules/registries') +const { dispatchStep } = require('../events/dispatch') +const log = require('./logger')('event-runner') + +const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000 + +// How many runs one sweep looks at, and how many steps it will drain from one +// run. Bounds rather than targets: the tick runs again in POLL_MS, and an +// unbounded batch is how a backlog turns one tick into a stall. The step bound +// also caps how long one run can hold the tick, which is what keeps a +// forty-step phase from starving every other run on the deployment. +const RUN_BATCH = Number(process.env.EVENT_RUN_BATCH) || 50 +const STEPS_PER_TICK = Number(process.env.EVENT_STEPS_PER_TICK) || 25 + +// A step's retries (org lead, 2026-09-02). §L specifies `retry(n) -> skip | +// pause | abort_run` and names no `n`; it lives here, as one number an operator +// can change, rather than in a column no authoring surface would ever show. +// +// Flat backoff rather than exponential, for the reason the outbox's is flat: +// `due_at` is also the event's own clock, and a doubling backoff pushes a step +// arbitrarily far past the moment the event was about. +const MAX_ATTEMPTS = Number(process.env.EVENT_STEP_MAX_ATTEMPTS) || 3 +const RETRY_MS = Number(process.env.EVENT_STEP_RETRY_MS) || 60_000 + +// How long a claim may look alive before the reclaim takes it back. The run +// lease has to outlast a whole tick's work on one run; a step's is computed from +// its own action's `budgetMs` (see `leaseFor`), because a registry that lets an +// action declare an hour would otherwise have its steps reclaimed and +// re-dispatched fifty-nine minutes before they answered. +const RUN_LEASE_MS = Number(process.env.EVENT_RUN_LEASE_MS) || 15 * 60 * 1000 +const STEP_LEASE_MARGIN_MS = 60_000 + +// The log retention horizon, and how often the sweep runs. It is folded into +// this tick rather than given a ninth timer because it shares the tick's only +// real dependency — a database — and a sweep that runs four times a day does not +// need an interval of its own. Only TERMINAL runs are ever eligible, which is the +// rule Engagement Phase 14 arrived at. +const LOG_RETENTION_DAYS = Number(process.env.EVENT_LOG_RETENTION_DAYS) || 90 +const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000 + +// Who this process is, for `claimed_by`. Host and pid, so a stranded claim in the +// table names the thing that stranded it. +const OWNER = `${os.hostname()}:${process.pid}`.slice(0, 64) + +const RUN_TERMINAL = runsDb.TERMINAL + +/** The lease a step's dispatch gets: its action's own budget, plus a margin. */ +function leaseFor(step, now) { + const action = registries.eventAction(step.action_id) + const budget = action?.budgetMs || 10_000 + return new Date(now.getTime() + budget + STEP_LEASE_MARGIN_MS) +} + +// ── The disposition of a step that has run out of road ───────────────────── +// +// **All three dispositions write the step `failed`.** `on_failure` says what +// happens to the RUN, not what happened to the step, and a step that was +// attempted three times and never worked is `failed` under every one of them. +// `skipped` is reserved for a step a human skipped from the run console (Phase +// 3) — a status that meant both "nobody ran this" and "this failed and we moved +// on" would make the run console's summary line unreadable. +async function applyFailure(run, step, error) { + await stepsDb.finish(step.id, 'failed', error) + await logDb.write({ + runId: run.id, + stepId: step.id, + kind: 'step.status', + phase: step.phase, + detail: { to: 'failed', action: step.action_id, attempts: step.attempts + 1, onFailure: step.on_failure, error }, + }) + + // A run that lost a step is degraded whatever happens next. Health is not + // status (§E): a run can be genuinely running and degraded at once, and the + // admin surface needs to say so without claiming the run stopped. + if (await runsDb.setHealth(run.id, 'degraded')) { + await logDb.write({ runId: run.id, kind: 'run.health', detail: { to: 'degraded', because: step.action_id } }) + } + + if (step.on_failure === 'abort_run') { + // §L: the disposition for `irreversible`. Nothing further is dispatched, and + // the pending steps are cancelled rather than left looking due forever. + const cancelled = await stepsDb.cancelPending(run.id) + await runsDb.transition(run.id, ['starting', 'running', 'ending'], 'failed', { error }) + await logDb.write({ + runId: run.id, + kind: 'run.status', + phase: step.phase, + detail: { to: 'failed', because: step.action_id, cancelledSteps: cancelled }, + }) + return 'stop' + } + + if (step.on_failure === 'pause') { + // The default for `change`, and the right one when the world is half-altered: + // stop advancing and wait for a human. `paused` is excluded from `findDue`, + // so nothing here picks it up again — Phase 3's resume control is the only + // thing that moves it. + await runsDb.transition(run.id, ['starting', 'running'], 'paused', { error }) + await logDb.write({ + runId: run.id, + kind: 'run.status', + phase: step.phase, + detail: { to: 'paused', because: step.action_id }, + }) + return 'stop' + } + + // 'skip': the run carries on, degraded, and the failure is on the record. + return 'continue' +} + +/** + * Claim one step, dispatch it, and record what came back. + * + * Answers `'continue'` (the phase may proceed), `'stop'` (it may not, for now or + * ever) or `'taken'` (somebody else claimed it first). + */ +async function drainStep(run, step, now, carry = {}) { + if (!(await stepsDb.claim(step.id, OWNER, leaseFor(step, now), now))) return 'taken' + + const result = await dispatchStep(step, { run }) + + if (result.actionVersionDrift) { + await logDb.write({ + runId: run.id, + stepId: step.id, + kind: 'step.status', + phase: step.phase, + detail: { action: step.action_id, versionDrift: result.actionVersionDrift }, + }) + } + + if (result.outcome === 'parked') { + // The GM cue. The step stays `running` with a NULL lease: genuinely in + // flight, nothing holding it, so the stale reclaim passes it by and a cue + // posted on Friday is still waiting on Monday. Phase 3's confirm control is + // what ends it. + await stepsDb.park(step.id, null) + await logDb.write({ + runId: run.id, + stepId: step.id, + kind: 'step.parked', + phase: step.phase, + detail: { action: step.action_id, params: step.params }, + }) + return 'stop' + } + + if (result.outcome === 'done') { + await stepsDb.finish(step.id, 'done', null) + if (result.holdSeconds > 0) { + // `core.wait`, and any module action that answers `holdFor`. The pause is + // the NEXT step's `due_at` and it is set here, by the runner, because a + // `perform()` that slept would hold its claim for the duration. + const until = new Date(now.getTime() + result.holdSeconds * 1000) + if (!(await stepsDb.holdNext(run.id, step.phase, step.seq, until))) { + // **Nothing after it in this phase**, which is the case a wait written as + // the last step of a phase produces. Dropping the hold here would make + // "announce, wait five minutes, then the next phase" start the next phase + // at once — a wait that silently meant nothing. The later phase's steps do + // not exist yet, so the instant is carried out to `advanceRun` and applied + // when they are materialised. + carry.holdUntil = until + } + } + await logDb.write({ + runId: run.id, + stepId: step.id, + kind: 'step.status', + phase: step.phase, + detail: { to: 'done', action: step.action_id, holdSeconds: result.holdSeconds || 0 }, + }) + return 'continue' + } + + if (result.outcome === 'retry' && step.attempts + 1 < MAX_ATTEMPTS) { + await stepsDb.reschedule(step.id, new Date(now.getTime() + RETRY_MS), result.error) + await logDb.write({ + runId: run.id, + stepId: step.id, + kind: 'step.retry', + phase: step.phase, + detail: { action: step.action_id, attempt: step.attempts + 1, of: MAX_ATTEMPTS, error: result.error }, + }) + // Degraded from the FIRST retry, not from the eventual failure. An event + // whose announcements are landing on the second attempt is having trouble + // now, and that is when an operator wants to know. + if (await runsDb.setHealth(run.id, 'degraded')) { + await logDb.write({ runId: run.id, kind: 'run.health', detail: { to: 'degraded', because: step.action_id } }) + } + return 'stop' + } + + // Terminal, or transient with the attempts spent. Same disposition either way: + // §L's `retry(n) -> ...` has arrived at the arrow. + return applyFailure(run, step, result.error) +} + +/** + * Advance one claimed run as far as it will go this tick. + * + * The loop is bounded by `STEPS_PER_TICK` and exits on the first thing it cannot + * get past — a parked step, a step whose `due_at` is in the future, a step + * somebody else holds, or a phase that is not finished. + */ +async function advanceRun(run, now) { + const version = await versionsDb.getById(run.version_id) + const phases = version?.spec?.phases + if (!Array.isArray(phases) || !phases.length) { + // The pinned version is unreadable. `version_id`'s foreign key RESTRICTs + // precisely so this cannot be a deleted row, so it is corruption rather than + // an ordinary race — terminal, named, and not retried. + await runsDb.transition(run.id, ['scheduled', 'starting', 'running', 'ending'], 'failed', { + error: 'the pinned version has no phases', + }) + await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'failed', because: 'pinned version has no phases' } }) + return 'failed' + } + + let phaseKey = run.current_phase + + if (run.status === 'starting') { + // Entering the first phase. `materialisePhase` is INSERT IGNORE against + // `uq_evstep_slot`, so doing it again over the rows Phase 1's `create()` + // already wrote is a no-op — which is what makes recovery from a process that + // died between the claim and here uneventful. + const first = phases[0] + await stepsDb.materialisePhase(run.id, first.key, first.steps || []) + if (!(await runsDb.transition(run.id, 'starting', 'running', { phase: first.key }))) return 'taken' + phaseKey = first.key + await logDb.write({ runId: run.id, kind: 'run.status', phase: first.key, detail: { from: 'starting', to: 'running' } }) + } + + if (run.status === 'ending') { + // A run that reached the wind-down and then lost its process. Phase 8 puts + // cleanup here; until then `ending` is a state a run passes through rather + // than one it does work in, and completing it is the whole recovery. + await runsDb.transition(run.id, 'ending', 'completed') + await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } }) + return 'completed' + } + + // A hold a `core.wait` could not place because nothing followed it in its own + // phase. It crosses the phase boundary with the run rather than being dropped. + const carry = {} + + for (let n = 0; n < STEPS_PER_TICK; n++) { + const phaseIndex = phases.findIndex((p) => p.key === phaseKey) + if (phaseIndex < 0) { + await runsDb.transition(run.id, ['running'], 'failed', { error: `phase "${phaseKey}" is not in the pinned version` }) + await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'failed', because: `unknown phase "${phaseKey}"` } }) + return 'failed' + } + + const step = await stepsDb.nextOpenStep(run.id, phaseKey) + + if (step && step.status === 'running') return 'in-flight' // parked, or somebody's dispatch + if (step && step.due_at && new Date(step.due_at) > now) return 'waiting' // behind a core.wait + + if (step) { + const outcome = await drainStep({ ...run, current_phase: phaseKey }, step, now, carry) + if (outcome === 'continue') continue + return outcome === 'taken' ? 'taken' : 'stopped' + } + + // Every step of this phase is terminal. + await logDb.write({ runId: run.id, kind: 'phase.completed', phase: phaseKey, detail: { index: phaseIndex } }) + + const next = phases[phaseIndex + 1] + if (!next) { + // §E's `ending` exists for the reason `sending` does in the outbox — it is + // what a claim sets — so the run passes through it even though Phase 2 has + // no cleanup to do there. Phase 8 is what gives it work. + if (!(await runsDb.transition(run.id, 'running', 'ending'))) return 'taken' + await logDb.write({ runId: run.id, kind: 'run.status', phase: phaseKey, detail: { from: 'running', to: 'ending' } }) + await runsDb.transition(run.id, 'ending', 'completed') + await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } }) + return 'completed' + } + + await stepsDb.materialisePhase(run.id, next.key, next.steps || []) + if (carry.holdUntil) { + // `seq > -1` is the first step of the phase just created. Applied after + // materialisation because that is the first moment there is a row to hold. + await stepsDb.holdNext(run.id, next.key, -1, carry.holdUntil) + carry.holdUntil = null + } + // `running -> running` is not a no-op: it is a guarded write of + // `current_phase` that fails if the run stopped being `running` underneath + // this tick, which is what a cancel from the admin surface looks like. + if (!(await runsDb.transition(run.id, 'running', 'running', { phase: next.key }))) return 'taken' + phaseKey = next.key + await logDb.write({ runId: run.id, kind: 'phase.entered', phase: next.key, detail: { steps: (next.steps || []).length } }) + } + + return 'bounded' // more to do; the next tick picks it up +} + +/** Claim one due run, work it, and hand the lease back if it is still in flight. */ +async function processRun(run, now = new Date()) { + if (run.status === 'scheduled') { + const holder = await runsDb.concurrencyHolder(run.concurrency_key, run.id) + if (holder) { + // The org lead's answer for a held key (2026-09-02): hold at `scheduled` + // and let the grace window decide. Nothing is destroyed, nothing starts + // silently late, and if the holder outlasts the window the missed sweep + // makes this run terminal and visible. + // + // Logged only when the reason CHANGES. A blocked run is re-examined every + // tick, and a line per tick for the length of a grace window would bury the + // one line that matters under a thousand identical ones. + const message = `held: run ${holder.id} has concurrency key "${run.concurrency_key}"` + if (run.last_error !== message) { + await runsDb.transition(run.id, 'scheduled', 'scheduled', { error: message }) + await logDb.write({ + runId: run.id, + kind: 'run.blocked', + detail: { concurrencyKey: run.concurrency_key, heldBy: holder.id, holderStatus: holder.status }, + }) + } + return 'blocked' + } + + if (!(await runsDb.claimStart(run.id, OWNER, new Date(now.getTime() + RUN_LEASE_MS)))) return 'taken' + await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'scheduled', to: 'starting' } }) + run = { ...run, status: 'starting' } + } else if (!(await runsDb.claimTick(run.id, OWNER, new Date(now.getTime() + RUN_LEASE_MS), now))) { + return 'taken' + } + + try { + const outcome = await advanceRun(run, now) + // A run that is not finished must not keep its lease: it would be + // unadvanceable until the lease expired, which would turn every `core.wait` + // into `max(wait, RUN_LEASE_MS)`. A terminal transition already cleared it. + if (!['completed', 'failed'].includes(outcome)) await runsDb.releaseClaim(run.id, OWNER) + return outcome + } catch (err) { + await runsDb.releaseClaim(run.id, OWNER) + throw err + } +} + +/** Occurrences that passed their own grace window while nothing was running (§L). */ +async function sweepMissed(now) { + const missed = await runsDb.findMissed(now) + let n = 0 + for (const run of missed) { + if (await runsDb.transition(run.id, 'scheduled', 'missed', { error: 'the grace window passed' })) { + await stepsDb.cancelPending(run.id) + await logDb.write({ + runId: run.id, + kind: 'run.status', + detail: { to: 'missed', scheduledFor: run.scheduled_for }, + }) + n += 1 + } + } + return n +} + +let lastPruneAt = 0 + +async function prune(now) { + if (now.getTime() - lastPruneAt < PRUNE_EVERY_MS) return 0 + lastPruneAt = now.getTime() + const before = new Date(now.getTime() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000) + const deleted = await logDb.pruneTerminal(before) + if (deleted) log.info('event run log pruned', { deleted, before, retentionDays: LOG_RETENTION_DAYS }) + return deleted +} + +async function tick(now = new Date()) { + try { + await runsDb.reclaimStale(now) + await stepsDb.reclaimStale(now, MAX_ATTEMPTS) + } catch (err) { + log.error('failed to reclaim stale claims', { message: err.message }) + } + + try { + await sweepMissed(now) + } catch (err) { + log.error('missed sweep failed', { message: err.message }) + } + + let due + try { + due = await runsDb.findDue(now, RUN_BATCH) + } catch (err) { + log.error('failed to load due runs', { message: err.message }) + return + } + + const counts = {} + for (const run of due || []) { + try { + const outcome = await processRun(run, now) + counts[outcome] = (counts[outcome] || 0) + 1 + } catch (err) { + log.error('run failed', { run: run.id, message: err.message }) + } + } + if (due && due.length) log.info('event runs swept', { due: due.length, ...counts }) + + try { + await prune(now) + } catch (err) { + log.error('log prune failed', { message: err.message }) + } +} + +let timer = null +// Guards against this process running two ticks over the same runs at once. +// `setInterval` fires whether or not the last callback returned, and the CAS +// alone does not cover it now that a live lease is not re-enterable by its own +// owner — a second tick would simply find every run claimed and do nothing +// useful, one query at a time, for as long as the first one ran. +let ticking = false + +function start() { + if (timer) return timer + timer = setInterval(() => { + if (ticking) { + log.warn('event tick still running; skipping this interval') + return + } + ticking = true + tick() + .catch((err) => log.error('event tick failed', { message: err.message })) + .finally(() => { + ticking = false + }) + }, POLL_MS) + if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown) + log.info('event runner started', { pollMs: POLL_MS, owner: OWNER, maxAttempts: MAX_ATTEMPTS }) + return timer +} + +function stop() { + if (timer) { + clearInterval(timer) + timer = null + } +} + +module.exports = { + start, + stop, + tick, + processRun, + advanceRun, + drainStep, + sweepMissed, + prune, + OWNER, + POLL_MS, + MAX_ATTEMPTS, + RETRY_MS, + RUN_LEASE_MS, + LOG_RETENTION_DAYS, + RUN_TERMINAL, +} diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js index c3882e1..d8fc957 100644 --- a/server/test/eventActionRegistry.test.js +++ b/server/test/eventActionRegistry.test.js @@ -63,17 +63,70 @@ test('the catalog carries no callable', () => { assert.equal(typeof registries.eventAction('core.wait').perform, 'function') }) -test('core placeholders refuse rather than claiming success', async () => { - // Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters - // because `ok: true` on an action that did nothing is a recorded world change - // that did not occur — the one wrong answer a stub can give. +test('core.announce refuses an unregistered leg terminally, and never claims success', async () => { + // Phase 1's version of this test asserted that all three core actions REFUSED, + // because none of them was wired yet. Phase 2 gave them real bodies, so what + // survives is the half that was never about the placeholder: `ok: true` on an + // action that did nothing is a recorded world change that did not occur. + // + // `core.announce` is the one that can still legitimately refuse. A leg nobody + // registers will not appear between two attempts a minute apart, so the answer + // is terminal rather than transient — a human has to fix it. registries.registerCore() - for (const id of ['core.announce', 'core.wait', 'core.cue']) { - const answer = await registries.eventAction(id).perform({}) - assert.equal(answer.ok, false) - assert.equal(answer.retry, false) - assert.match(answer.error, new RegExp(id.replace('.', '\\.'))) + const answer = await registries.eventAction('core.announce').perform({ + params: { leg: 'nowhere', body: 'hello' }, + }) + assert.equal(answer.ok, false) + assert.equal(answer.retry, false) + assert.match(answer.error, /nowhere/) +}) + +test('a dry run validates and reports, but dispatches nothing', async () => { + // §I's dry run: `verify === true` means validate and report, change nothing. + // `core.announce` is the only core action with an outside effect to suppress. + // + // **The leg is resolved BEFORE `verify` is honoured, and that ordering is the + // point rather than an oversight.** A dry run exists to report what would + // happen, and "this step names a leg nobody registers" is the most useful thing + // it can find. Answering `ok: true` first would make the dry run pass on + // exactly the definition that cannot work. + registries.registerCore() + const announce = registries.eventAction('core.announce') + + const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true }) + assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist') + + // A registered leg: reported good, and its transport never touched. + const leg = registries.announceLeg('discord') + const dispatch = leg.dispatch + let dispatched = 0 + leg.dispatch = async () => { + dispatched += 1 + return { ok: true } } + try { + const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true }) + assert.equal(good.ok, true) + assert.equal(dispatched, 0, 'a dry run sends nothing') + } finally { + leg.dispatch = dispatch + } +}) + +test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => { + // Both answer through ordinary envelope members, which is what lets the runner + // honour them without knowing what either action is. A `perform` that slept + // would hold its claim for the duration and turn a five-minute pause into a + // five-minute lease. + registries.registerCore() + assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), { + ok: true, + holdFor: 300, + }) + assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), { + ok: true, + await: 'human', + }) }) test('an action must be namespaced to its owner, and the holder is named', () => { diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js new file mode 100644 index 0000000..33b1abf --- /dev/null +++ b/server/test/eventRunner.test.js @@ -0,0 +1,689 @@ +// ── The event runner (EVENTS_PLAN.md Phase 2) ────────────────────────────── +// +// The phase's shipped claim, first: **a manually started event that broadcasts, +// waits, and completes.** Then the properties around it that are not behaviour +// so much as promises — the ones §E and §L make, and the two this codebase has +// already paid for once: +// +// • a reclaim never resets `attempts` (Engagement Phase 14's defect) +// • a parked GM cue is not stale, however long it waits +// • no shape a failure can take reads as success (§F) +// • a step naming an unregistered action fails terminal with the module named +// and degrades the run — never a silent skip (§L) +// • the three `on_failure` dispositions do three different things to the RUN +// • the idempotency key does not vary by attempt (§E) +// +// **The three tables are stubbed at the `.db` layer** and the runner's own logic +// runs for real against them — the shape `engagementEngine.test.js` uses. What a +// stub cannot prove is the raw SQL whose correctness IS a server contract: the +// two CAS claims, the lease reclaim's two-statement order, and `holdNext`'s +// guard. Those run against a real MariaDB in `eventRunnerSql.test.js`, which +// skips when there is none. A stub reproduces the reading, not the server. +// +// Point the DB at a closed port before requiring anything: the registries reach +// utils/discordAnnounce, which builds the pool at require time. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const runner = require('../src/utils/eventRunner') +const { classify } = require('../src/events/dispatch') +const runsDb = require('../src/model/events/eventRuns.db') +const stepsDb = require('../src/model/events/eventRunSteps.db') +const logDb = require('../src/model/events/eventRunLog.db') +const versionsDb = require('../src/model/events/eventVersions.db') +const db = require('../src/utils/db') + +after(() => db.close()) + +const T0 = new Date('2026-09-02T12:00:00Z') +const later = (ms) => new Date(T0.getTime() + ms) + +// ── In-memory stand-ins for the three tables ─────────────────────────────── + +let store +const originals = {} + +for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb]]) { + originals[name] = { mod, fns: { ...mod } } +} + +const restoreOriginals = () => { + for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) +} + +const TERMINAL_RUN = ['completed', 'cancelled', 'failed', 'missed'] +const clone = (o) => JSON.parse(JSON.stringify(o, (k, v) => v)) + +function installStubs() { + store = { + runs: new Map(), + steps: new Map(), + log: [], + versions: new Map(), + definitions: new Map(), + nextStepId: 1, + } + + // Snapshots, not live references. A SQL SELECT hands back a copy, and the + // runner reads `step.attempts` as the value BEFORE its own claim incremented + // it — returning references here would make the retry budget off by one in the + // stub only, which is exactly the class of thing a stub must not invent. + const snapRun = (r) => ({ ...r }) + const snapStep = (s) => ({ ...s, params: { ...(s.params || {}) } }) + + runsDb.findDue = async (now) => + [...store.runs.values()] + .filter((r) => ['scheduled', 'starting', 'running', 'ending'].includes(r.status) && r.scheduled_for <= now) + .sort((a, b) => a.scheduled_for - b.scheduled_for || a.id - b.id) + .map(snapRun) + + runsDb.findMissed = async (now) => + [...store.runs.values()] + .filter((r) => { + const grace = store.definitions.get(r.definition_id)?.grace_seconds ?? 900 + return r.status === 'scheduled' && r.scheduled_for.getTime() + grace * 1000 < now.getTime() + }) + .map(snapRun) + + runsDb.claimStart = async (id, owner, lease) => { + const r = store.runs.get(id) + if (!r || r.status !== 'scheduled') return false + Object.assign(r, { status: 'starting', claimed_by: owner, claim_expires_at: lease, started_at: r.started_at || T0 }) + return true + } + + runsDb.claimTick = async (id, owner, lease, now) => { + const r = store.runs.get(id) + if (!r || !['starting', 'running', 'ending'].includes(r.status)) return false + // No owner-matches escape: a live lease is not re-enterable, not even by the + // process that took it. The stub agrees with the statement on purpose. + if (r.claim_expires_at && r.claim_expires_at >= now) return false + Object.assign(r, { claimed_by: owner, claim_expires_at: lease }) + return true + } + + runsDb.releaseClaim = async (id, owner) => { + const r = store.runs.get(id) + if (!r || r.claimed_by !== owner) return false + Object.assign(r, { claimed_by: null, claim_expires_at: null }) + return true + } + + runsDb.transition = async (id, from, to, opts = {}) => { + const r = store.runs.get(id) + const froms = Array.isArray(from) ? from : [from] + if (!r || !froms.includes(r.status)) return false + r.status = to + if (opts.phase !== undefined) r.current_phase = opts.phase + if (opts.error !== undefined) r.last_error = opts.error + if (TERMINAL_RUN.includes(to)) { + r.ended_at = r.ended_at || T0 + r.claimed_by = null + r.claim_expires_at = null + } else if (opts.clearClaim) { + r.claimed_by = null + r.claim_expires_at = null + } + return true + } + + runsDb.setHealth = async (id, health) => { + const r = store.runs.get(id) + if (!r || r.health === health) return false + r.health = health + return true + } + + runsDb.concurrencyHolder = async (key, exceptId) => { + if (!key) return null + const held = [...store.runs.values()].find( + (r) => r.concurrency_key === key && r.id !== exceptId && ['starting', 'running', 'paused', 'ending'].includes(r.status), + ) + return held ? { id: held.id, status: held.status, definition_id: held.definition_id } : null + } + + runsDb.reclaimStale = async (now) => { + let n = 0 + for (const r of store.runs.values()) { + if (['starting', 'running', 'ending'].includes(r.status) && r.claim_expires_at && r.claim_expires_at < now) { + r.claimed_by = null + r.claim_expires_at = null + n += 1 + } + } + return n + } + + stepsDb.materialisePhase = async (runId, phase, steps) => { + steps.forEach((s, i) => { + // INSERT IGNORE against uq_evstep_slot (run_id, phase, seq). + const exists = [...store.steps.values()].find((x) => x.run_id === runId && x.phase === phase && x.seq === i) + if (exists) return + const id = store.nextStepId++ + store.steps.set(id, { + id, + run_id: runId, + phase, + seq: i, + action_id: s.actionId, + params: s.params || {}, + action_version: s.actionVersion || 1, + status: 'pending', + due_at: null, + attempts: 0, + on_failure: s.onFailure || 'pause', + idempotency_key: stepsDb.idempotencyKey(runId, id), + claimed_by: null, + claim_expires_at: null, + last_error: null, + }) + }) + return [...store.steps.values()].filter((s) => s.run_id === runId).map(snapStep) + } + + stepsDb.nextOpenStep = async (runId, phase) => { + const s = [...store.steps.values()] + .filter((x) => x.run_id === runId && x.phase === phase && ['pending', 'running'].includes(x.status)) + .sort((a, b) => a.seq - b.seq || a.id - b.id)[0] + return s ? snapStep(s) : null + } + + stepsDb.claim = async (id, owner, lease, now) => { + const s = store.steps.get(id) + if (!s || s.status !== 'pending') return false + if (s.due_at && s.due_at > now) return false + Object.assign(s, { status: 'running', attempts: s.attempts + 1, claimed_by: owner, claim_expires_at: lease }) + return true + } + + stepsDb.park = async (id) => { + const s = store.steps.get(id) + if (s && s.status === 'running') s.claim_expires_at = null + } + + stepsDb.reschedule = async (id, dueAt, error) => { + const s = store.steps.get(id) + if (!s || s.status !== 'running') return + // `attempts` is untouched: the claim already incremented it, and nothing else + // may. This is the stub agreeing with the statement, not with the runner. + Object.assign(s, { status: 'pending', due_at: dueAt, claimed_by: null, claim_expires_at: null, last_error: error }) + } + + stepsDb.finish = async (id, status, error) => { + const s = store.steps.get(id) + if (!s || s.status !== 'running') return + Object.assign(s, { status, last_error: error, claimed_by: null, claim_expires_at: null, finished_at: T0 }) + } + + stepsDb.holdNext = async (runId, phase, afterSeq, dueAt) => { + const s = [...store.steps.values()] + .filter((x) => x.run_id === runId && x.phase === phase && x.seq > afterSeq && x.status === 'pending') + .filter((x) => !x.due_at || x.due_at < dueAt) + .sort((a, b) => a.seq - b.seq)[0] + if (!s) return false + s.due_at = dueAt + return true + } + + stepsDb.reclaimStale = async (now, maxAttempts = 0) => { + let failed = 0 + let reclaimed = 0 + // Give up first, reclaim second — the order the statement uses, and the + // reason `MAX_ATTEMPTS` is reachable at all. + for (const s of store.steps.values()) { + if (s.status === 'running' && s.claim_expires_at && s.claim_expires_at < now && maxAttempts > 0 && s.attempts >= maxAttempts) { + Object.assign(s, { status: 'failed', last_error: 'gave up after repeated interruptions', claimed_by: null, claim_expires_at: null }) + failed += 1 + } + } + for (const s of store.steps.values()) { + if (s.status === 'running' && s.claim_expires_at && s.claim_expires_at < now) { + // NOT reset: attempts survives the reclaim. + Object.assign(s, { status: 'pending', claimed_by: null, claim_expires_at: null }) + reclaimed += 1 + } + } + return { failed, reclaimed } + } + + stepsDb.cancelPending = async (runId) => { + let n = 0 + for (const s of store.steps.values()) { + if (s.run_id === runId && s.status === 'pending') { + s.status = 'cancelled' + n += 1 + } + } + return n + } + + stepsDb.listForRun = async (runId) => + [...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq).map(snapStep) + + logDb.write = async (line) => { + store.log.push(line) + return true + } + logDb.pruneTerminal = async () => 0 + + versionsDb.getById = async (id) => store.versions.get(id) || null +} + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +let nextRunId = 1 + +function seedRun(phases, { scheduledFor = T0, graceSeconds = 900, concurrencyKey = null, status = 'scheduled' } = {}) { + const id = nextRunId++ + store.definitions.set(id, { id, grace_seconds: graceSeconds }) + store.versions.set(id, { id, spec: { schedule: { kind: 'manual' }, phases } }) + store.runs.set(id, { + id, + definition_id: id, + version_id: id, + scope: '', + status, + health: 'ok', + cleanup_status: 'not_required', + current_phase: null, + scheduled_for: scheduledFor, + concurrency_key: concurrencyKey, + params: null, + rehearsal: 0, + claimed_by: null, + claim_expires_at: null, + last_error: null, + started_at: null, + ended_at: null, + }) + // Phase 1's `create()` materialises the FIRST phase at creation rather than at + // start, so a seeded run has to as well — otherwise every test here would be + // exercising a shape the admin route cannot produce. + const first = phases[0] + if (first) void stepsDb.materialisePhase(id, first.key, first.steps || []) + return id +} + +const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params, onFailure, actionVersion: 1 }) +const run = (id) => store.runs.get(id) +const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq) +const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind) + +// A registered test action whose behaviour the test dictates. +let scripted + +beforeEach(() => { + registries._reset() + installStubs() + nextRunId = 1 + scripted = {} +}) + +afterEach(() => { + restoreOriginals() + registries._reset() +}) + +/** Register actions the way a module does, through the real staging area. */ +const register = (entries, owner = 'test') => { + const api = registries.stage(owner) + api.registerEventActions(entries) + registries.apply(api.staged) +} + +// ── Registering actions the tests drive ──────────────────────────────────── +// +// Registered through the real registry rather than by stubbing `eventAction`, +// because the shape check at registration is part of what the runner relies on: +// an action that would not register is not one the runner has to survive. + +const scriptedAction = (id, extra = {}) => ({ + id, + label: id, + risk: 'notify', + reversible: 'none', + version: 1, + budgetMs: 1000, + params: [], + perform: async (envelope) => { + ;(scripted[id] ||= { calls: [] }).calls.push(envelope) + const answer = scripted[id].answers?.shift() ?? scripted[id].answer + if (typeof answer === 'function') return answer(envelope) + return answer ?? { ok: true } + }, + ...extra, +}) + +test('a manually started event announces, waits and completes', async () => { + register([scriptedAction('test.announce'), scriptedAction('test.wait')]) + scripted['test.wait'] = { calls: [], answer: { ok: true, holdFor: 300 } } + + const id = seedRun([ + { key: 'main', label: 'Main', steps: [step('test.announce'), step('test.wait'), step('test.announce')] }, + ]) + + // Tick one: announce, then wait, then stop against the held third step. + await runner.tick(T0) + let s = stepsOf(id) + assert.equal(s[0].status, 'done') + assert.equal(s[1].status, 'done') + assert.equal(s[2].status, 'pending', 'the step after a wait must not run in the same tick') + assert.equal(s[2].due_at.getTime(), later(300_000).getTime(), 'the wait is the NEXT step due_at') + assert.equal(run(id).status, 'running') + assert.equal(run(id).claimed_by, null, 'a run left in flight gives its lease back') + + // Tick two, still inside the wait: nothing moves. + await runner.tick(later(120_000)) + assert.equal(stepsOf(id)[2].status, 'pending') + assert.equal(run(id).status, 'running') + + // Tick three, past it: the last step runs and the run completes. + await runner.tick(later(301_000)) + assert.equal(stepsOf(id)[2].status, 'done') + assert.equal(run(id).status, 'completed') + assert.equal(run(id).health, 'ok') + assert.ok(kinds(id).includes('phase.completed')) + assert.ok(kinds(id).includes('run.status')) +}) + +test('a run passes through `ending` on its way to completed', async () => { + register([scriptedAction('test.noop')]) + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }]) + await runner.tick(T0) + + const transitions = store.log.filter((l) => l.runId === id && l.kind === 'run.status').map((l) => l.detail.to) + assert.deepEqual(transitions, ['starting', 'running', 'ending', 'completed']) +}) + +test('phases run in order and the next one is materialised on entry', async () => { + register([scriptedAction('test.noop')]) + + const id = seedRun([ + { key: 'opening', label: 'Opening', steps: [step('test.noop')] }, + { key: 'closing', label: 'Closing', steps: [step('test.noop'), step('test.noop')] }, + ]) + + await runner.tick(T0) + assert.equal(run(id).status, 'completed') + assert.deepEqual(stepsOf(id).map((s) => s.phase), ['opening', 'closing', 'closing']) + assert.ok(stepsOf(id).every((s) => s.status === 'done')) +}) + +test('a wait as the last step of a phase holds the NEXT phase, rather than meaning nothing', async () => { + register([scriptedAction('test.noop'), scriptedAction('test.wait')]) + scripted['test.wait'] = { calls: [], answer: { ok: true, holdFor: 300 } } + + const id = seedRun([ + { key: 'opening', label: 'Opening', steps: [step('test.noop'), step('test.wait')] }, + { key: 'closing', label: 'Closing', steps: [step('test.noop')] }, + ]) + + await runner.tick(T0) + const closing = stepsOf(id).filter((x) => x.phase === 'closing') + assert.equal(closing.length, 1, 'the next phase is materialised') + assert.equal(closing[0].status, 'pending') + assert.equal( + closing[0].due_at.getTime(), + later(300_000).getTime(), + 'the hold crosses the phase boundary; dropping it would start the next phase at once', + ) + assert.equal(run(id).status, 'running') + + await runner.tick(later(301_000)) + assert.equal(run(id).status, 'completed') +}) + +test('a GM cue parks: the step stays running with no lease, and the reclaim leaves it alone', async () => { + register([scriptedAction('test.cue'), scriptedAction('test.after')]) + scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } } + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue'), step('test.after')] }]) + + await runner.tick(T0) + const cue = stepsOf(id)[0] + assert.equal(cue.status, 'running') + assert.equal(cue.claim_expires_at, null, 'a parked step carries no lease') + assert.equal(stepsOf(id)[1].status, 'pending', 'nothing after a cue proceeds') + assert.ok(kinds(id).includes('step.parked')) + + // A week later the reclaim has still not touched it, and the cue has been + // dispatched exactly once. This is the whole point of a NULL lease. + await runner.tick(later(7 * 24 * 60 * 60 * 1000)) + assert.equal(stepsOf(id)[0].status, 'running') + assert.equal(stepsOf(id)[0].attempts, 1) + assert.equal(scripted['test.cue'].calls.length, 1) + assert.equal(run(id).status, 'running') +}) + +test('a reclaim returns a stale step without resetting attempts', async () => { + register([scriptedAction('test.slow')]) + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.slow')] }]) + + // Simulate a process that claimed the step and died: running, lease in the past. + await runner.tick(T0) + const s = stepsOf(id)[0] + Object.assign(store.steps.get(s.id), { status: 'running', attempts: 2, claim_expires_at: later(-1000) }) + + await stepsDb.reclaimStale(T0, runner.MAX_ATTEMPTS) + assert.equal(store.steps.get(s.id).status, 'pending') + assert.equal(store.steps.get(s.id).attempts, 2, 'Engagement Phase 14: a reclaim must never reset attempts') +}) + +test('a step whose attempts are spent leaves `running` as failed rather than being handed back', async () => { + register([scriptedAction('test.slow')]) + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.slow')] }]) + await runner.tick(T0) + const s = stepsOf(id)[0] + Object.assign(store.steps.get(s.id), { status: 'running', attempts: runner.MAX_ATTEMPTS, claim_expires_at: later(-1000) }) + + const { failed, reclaimed } = await stepsDb.reclaimStale(T0, runner.MAX_ATTEMPTS) + assert.equal(failed, 1) + assert.equal(reclaimed, 0, 'a row that gave up must not also be reclaimed, or it retries forever') + assert.equal(store.steps.get(s.id).status, 'failed') +}) + +test('a transient failure retries on a flat backoff with the same idempotency key, then applies on_failure', async () => { + register([scriptedAction('test.flaky')]) + scripted['test.flaky'] = { calls: [], answer: { ok: false, retry: true, error: 'relay is down' } } + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.flaky', {}, 'skip')] }]) + const key = () => stepsOf(id)[0].idempotency_key + + await runner.tick(T0) + const firstKey = key() + assert.equal(stepsOf(id)[0].status, 'pending') + assert.equal(stepsOf(id)[0].attempts, 1) + assert.equal(stepsOf(id)[0].due_at.getTime(), later(runner.RETRY_MS).getTime()) + assert.equal(run(id).health, 'degraded', 'degraded from the first retry, not from the eventual failure') + + await runner.tick(later(runner.RETRY_MS)) + assert.equal(stepsOf(id)[0].attempts, 2) + + await runner.tick(later(2 * runner.RETRY_MS)) + assert.equal(stepsOf(id)[0].attempts, runner.MAX_ATTEMPTS) + assert.equal(stepsOf(id)[0].status, 'failed', 'all three dispositions write the step failed') + assert.equal(run(id).status, 'completed', 'on_failure: skip lets the run finish') + assert.equal(run(id).health, 'degraded') + + assert.equal(key(), firstKey, 'the idempotency key does not vary by attempt') + assert.equal(new Set(scripted['test.flaky'].calls.map((c) => c.idempotencyKey)).size, 1) +}) + +test('on_failure: pause stops the run and the tick never picks it up again', async () => { + register([scriptedAction('test.bad'), scriptedAction('test.after')]) + scripted['test.bad'] = { calls: [], answer: { ok: false, retry: false, error: 'the world is half changed' } } + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.bad', {}, 'pause'), step('test.after')] }]) + + await runner.tick(T0) + assert.equal(run(id).status, 'paused') + assert.equal(stepsOf(id)[0].status, 'failed') + assert.equal(stepsOf(id)[1].status, 'pending', 'a paused run leaves its remaining steps alone') + + await runner.tick(later(60_000)) + assert.equal(run(id).status, 'paused', 'only Phase 3 resume moves a paused run') + assert.equal(scripted['test.after']?.calls?.length ?? 0, 0) +}) + +test('on_failure: abort_run fails the run and cancels what has not started', async () => { + register([scriptedAction('test.bad'), scriptedAction('test.after')]) + scripted['test.bad'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } } + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.bad', {}, 'abort_run'), step('test.after')] }]) + + await runner.tick(T0) + assert.equal(run(id).status, 'failed') + assert.equal(stepsOf(id)[0].status, 'failed') + assert.equal(stepsOf(id)[1].status, 'cancelled') +}) + +test('a step naming an unregistered action fails terminal with the module named, and degrades the run', async () => { + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('gone.verb', {}, 'skip')] }]) + + await runner.tick(T0) + const s = stepsOf(id)[0] + assert.equal(s.status, 'failed', 'never a silent skip (§L)') + assert.equal(s.attempts, 1, 'a dormant action is terminal, so it is not retried') + assert.match(s.last_error, /gone\.verb/) + assert.equal(run(id).health, 'degraded') +}) + +test('a held concurrency key holds the run at scheduled, and logs the reason once', async () => { + register([scriptedAction('test.noop'), scriptedAction('test.cue')]) + scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } } + + // The holder is parked on a cue, which is what keeps it genuinely in flight. A + // holder with no steps would complete itself on this same tick — correct + // behaviour, and a fixture that proved nothing. + const holder = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue')] }], { + concurrencyKey: 'invasion:Yew', + }) + const waiting = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { concurrencyKey: 'invasion:Yew' }) + + await runner.tick(T0) + assert.equal(run(waiting).status, 'scheduled') + assert.match(run(waiting).last_error, new RegExp(`run ${holder}`)) + assert.equal(kinds(waiting).filter((k) => k === 'run.blocked').length, 1) + + // Still held, and still one line: a line per tick would bury the one that matters. + await runner.tick(later(15_000)) + assert.equal(kinds(waiting).filter((k) => k === 'run.blocked').length, 1) + + // The holder finishes, and the next tick starts the run that was waiting. + store.runs.get(holder).status = 'completed' + store.runs.get(holder).claim_expires_at = null + await runner.tick(later(30_000)) + assert.equal(run(waiting).status, 'completed') +}) + +test('an occurrence past its own grace window is missed, never a late silent start', async () => { + register([scriptedAction('test.noop')]) + + const late = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { graceSeconds: 600 }) + const inside = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { graceSeconds: 3600 }) + + // Both are due; the process has been down for half an hour. + await runner.tick(later(30 * 60 * 1000)) + + assert.equal(run(late).status, 'missed') + assert.equal(stepsOf(late)[0].status, 'cancelled') + assert.equal(run(inside).status, 'completed', 'inside its window it starts late and says so') +}) + +test('a run in `ending` when the process died is completed by the next tick', async () => { + const id = seedRun([{ key: 'main', label: 'Main', steps: [] }], { status: 'ending' }) + store.runs.get(id).current_phase = 'main' + + await runner.tick(T0) + assert.equal(run(id).status, 'completed') +}) + +test('a live lease is not re-enterable, not even by the process that took it', async () => { + register([scriptedAction('test.cue')]) + scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } } + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue')] }]) + await runner.tick(T0) + + // Put a live lease back on the run, as an overrunning tick would have. + Object.assign(store.runs.get(id), { claimed_by: runner.OWNER, claim_expires_at: later(60_000) }) + const taken = await runsDb.claimTick(id, runner.OWNER, later(120_000), T0) + assert.equal(taken, false, 'the CAS is what protects a tick that overran into the next one') +}) + +// ── §F: no shape a failure can take reads as success ─────────────────────── + +test('classify: every failure shape is a failure', () => { + assert.equal(classify(undefined, 'a').outcome, 'retry') + assert.equal(classify(null, 'a').outcome, 'retry') + assert.equal(classify('ok', 'a').outcome, 'retry') + assert.equal(classify(['ok'], 'a').outcome, 'retry') + assert.equal(classify({}, 'a').outcome, 'retry', 'a missing ok is not a success') + assert.equal(classify({ ok: 'yes' }, 'a').outcome, 'retry', 'ok must be true, not truthy') + assert.equal(classify({ ok: false }, 'a').outcome, 'retry') + assert.equal(classify({ ok: false, retry: false }, 'a').outcome, 'terminal') + assert.equal(classify({ __timedOut: true, error: 'slow' }, 'a').outcome, 'retry') +}) + +test('classify: the two success shapes that are not "finished"', () => { + assert.equal(classify({ ok: true }, 'a').outcome, 'done') + assert.equal(classify({ ok: true }, 'a').holdSeconds, 0) + assert.equal(classify({ ok: true, await: 'human' }, 'a').outcome, 'parked') + assert.equal(classify({ ok: true, holdFor: 90 }, 'a').holdSeconds, 90) + assert.equal(classify({ ok: true, holdFor: '90' }, 'a').holdSeconds, 90) + assert.equal(classify({ ok: true, holdFor: -1 }, 'a').outcome, 'terminal', 'a bad holdFor is not a silent zero') + assert.equal(classify({ ok: true, holdFor: 'soon' }, 'a').outcome, 'terminal') + assert.ok(classify({ ok: true, holdFor: 1e12 }, 'a').holdSeconds <= 7 * 24 * 60 * 60, 'holdFor is bounded') +}) + +test('an action that throws is a transient failure, not a crashed tick', async () => { + register([ + scriptedAction('test.thrower', { + perform: async () => { + throw new Error('boom') + }, + }), + ]) + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.thrower', {}, 'skip')] }]) + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'pending') + assert.match(stepsOf(id)[0].last_error, /boom/) + assert.equal(run(id).status, 'running', 'one bad action does not stop the deployment') +}) + +test('an action that never answers is cut off at its declared budget', async () => { + register([ + scriptedAction('test.hang', { budgetMs: 30, perform: () => new Promise(() => {}) }), + ]) + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.hang', {}, 'skip')] }]) + await runner.tick(T0) + + assert.equal(stepsOf(id)[0].status, 'pending', 'a timeout is transient') + assert.match(stepsOf(id)[0].last_error, /budget/) +}) + +test('the dispatch envelope carries what §F says it carries', async () => { + register([scriptedAction('test.echo')]) + + const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.echo', {})] }]) + store.runs.get(id).scope = 'atlantic' + await runner.tick(T0) + + const [envelope] = scripted['test.echo'].calls + assert.equal(envelope.runId, id) + assert.equal(envelope.scope, 'atlantic') + assert.equal(envelope.verify, false) + assert.equal(typeof envelope.idempotencyKey, 'string') + assert.equal(envelope.idempotencyKey.length, 40) + assert.deepEqual(Object.keys(envelope).sort(), ['actor', 'idempotencyKey', 'params', 'runId', 'scope', 'stepId', 'verify']) +}) diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js new file mode 100644 index 0000000..5db23a5 --- /dev/null +++ b/server/test/eventRunnerSql.test.js @@ -0,0 +1,508 @@ +// ── The runner's raw SQL, against a real MariaDB ─────────────────────────── +// +// EVENTS_PLAN.md Phase 2. `eventRunner.test.js` stubs the three tables and +// exercises everything the runner DECIDES. It cannot prove the statements whose +// whole correctness is a server contract, and on this codebase that gap has +// already cost something once: engagement's cooldown claim was green against its +// stub and always allowed the send against a real server, because the connector +// defaults `foundRows: true` and a no-op UPDATE reports 1 rather than 0. +// +// So the five statements that decide who owns what run here, for real: +// +// • **`claimStart`** — the CAS `scheduled -> starting`. "Exactly one winner" is +// `affectedRows = 1` for one caller and 0 for every other, and that is a +// property of the SERVER's answer, not of the SQL's shape. +// • **`claimTick`** — the same, for a run already in flight, and with no +// owner-matches escape clause. A live lease must refuse its own holder, or +// one `setInterval` that overran advances one run twice. +// • **`transition`** — a guarded status move. The guard is the whole thing: a +// run cancelled between the read and the write must not be transitioned. +// • **`reclaimStale`** on steps — two statements in a fixed ORDER, give-up +// before hand-back. Reversing them makes `MAX_ATTEMPTS` unreachable and the +// row cycles forever (Engagement Phase 14's defect), and **neither statement +// may touch `attempts`**. +// • **`holdNext`** — `UPDATE ... ORDER BY seq LIMIT 1` with a guard, which is +// both a MariaDB-specific syntax and a correctness claim: it must move the +// next PENDING step and only ever push a due date later. +// +// Plus the two unique indexes that are load-bearing rather than tidy: +// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one +// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising +// a phase a no-op). +// +// **It SKIPS when there is no database**, deliberately: CI runs the suite with +// the pool pointed at a dead port, and a file that failed there would make every +// PR red for a reason unrelated to itself. Run it against this machine's +// container with: +// +// DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=... DB_PASSWORD=... \ +// node --test test/eventRunnerSql.test.js +// +// It creates its tables in a throwaway database named after the process and +// drops it again, so it can never touch a real schema. + +const { test, before, after, beforeEach } = require('node:test') +const assert = require('node:assert/strict') +const mariadb = require('mariadb') + +// Trimmed to the columns these statements read or write. The ENUMs are verbatim, +// because "is `missed` a legal value" is one of the things being proved. +const SCHEMA = ` +CREATE TABLE event_definitions ( + id INT AUTO_INCREMENT PRIMARY KEY, + grace_seconds INT NOT NULL DEFAULT 900 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE event_runs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + definition_id INT NOT NULL, + version_id INT NOT NULL, + scope VARCHAR(190) NOT NULL DEFAULT '', + status ENUM('scheduled','starting','running','paused','ending', + 'completed','cancelled','failed','missed') + NOT NULL DEFAULT 'scheduled', + health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok', + current_phase VARCHAR(64) NULL, + scheduled_for DATETIME NOT NULL, + concurrency_key VARCHAR(190) NULL, + started_at DATETIME NULL, + ended_at DATETIME NULL, + claimed_by VARCHAR(64) NULL, + claim_expires_at DATETIME NULL, + last_error VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for), + INDEX idx_evrun_due (status, scheduled_for), + INDEX idx_evrun_concurrency (concurrency_key, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE event_run_steps ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + run_id BIGINT NOT NULL, + phase VARCHAR(64) NOT NULL, + seq INT NOT NULL, + action_id VARCHAR(96) NOT NULL, + params JSON NULL, + action_version INT NOT NULL DEFAULT 1, + status ENUM('pending','running','done','failed','skipped','refused','cancelled') + NOT NULL DEFAULT 'pending', + due_at DATETIME NULL, + attempts INT NOT NULL DEFAULT 0, + on_failure VARCHAR(32) NOT NULL DEFAULT 'skip', + idempotency_key CHAR(40) NOT NULL, + claimed_by VARCHAR(64) NULL, + claim_expires_at DATETIME NULL, + last_error VARCHAR(500) NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_evstep_slot (run_id, phase, seq), + INDEX idx_evstep_due (status, due_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +` + +// The statements under test, verbatim from `eventRuns.db.js` and +// `eventRunSteps.db.js`. Duplicated rather than required, because requiring the +// modules would drag in `utils/db`'s pool, which the harness has already pointed +// at a dead port. The pool below leaves `foundRows` at the connector's default, +// exactly as `utils/db.js` does — pinning it here would make this file agree with +// the code by construction and prove nothing about the pool the server runs. +const CLAIM_START = ` +UPDATE event_runs + SET status = 'starting', claimed_by = ?, claim_expires_at = ?, + started_at = COALESCE(started_at, NOW()) + WHERE id = ? AND status = 'scheduled'` + +const CLAIM_TICK = ` +UPDATE event_runs + SET claimed_by = ?, claim_expires_at = ? + WHERE id = ? + AND status IN ('starting','running','ending') + AND (claim_expires_at IS NULL OR claim_expires_at < ?)` + +const TRANSITION = ` +UPDATE event_runs SET status = ?, current_phase = ? + WHERE id = ? AND status IN (?)` + +const CLAIM_STEP = ` +UPDATE event_run_steps + SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?, + started_at = COALESCE(started_at, NOW()) + WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)` + +const STEP_GIVE_UP = ` +UPDATE event_run_steps + SET status = 'failed', last_error = 'gave up after repeated interruptions', + finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL + WHERE status = 'running' + AND claim_expires_at IS NOT NULL AND claim_expires_at < ? + AND attempts >= ?` + +const STEP_RECLAIM = ` +UPDATE event_run_steps + SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL + WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?` + +const HOLD_NEXT = ` +UPDATE event_run_steps + SET due_at = ? + WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending' + AND (due_at IS NULL OR due_at < ?) + ORDER BY seq LIMIT 1` + +const MATERIALISE_RUN = ` +INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key) +VALUES (?, ?, ?, ?, ?)` + +const MATERIALISE_STEP = ` +INSERT IGNORE INTO event_run_steps (run_id, phase, seq, action_id, idempotency_key) +VALUES (?, ?, ?, ?, ?)` + +const FIND_MISSED = ` +SELECT r.id FROM event_runs r + JOIN event_definitions d ON d.id = r.definition_id + WHERE r.status = 'scheduled' + AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?` + +const DB = `rg_events_test_${process.pid}` +let pool = null +let available = false + +const poolOpts = () => ({ + host: process.env.DB_HOST || '127.0.0.1', + port: Number(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', +}) + +before(async () => { + const admin = mariadb.createPool({ + ...poolOpts(), + connectionLimit: 1, + connectTimeout: 2000, + initializationTimeout: 2000, + multipleStatements: true, + }) + try { + await admin.query(`CREATE DATABASE ${DB}`) + available = true + } catch { + available = false + } finally { + await admin.end().catch(() => {}) + } + if (!available) return + + pool = mariadb.createPool({ + ...poolOpts(), + database: DB, + connectionLimit: 3, + multipleStatements: true, + bigIntAsNumber: true, + insertIdAsNumber: true, + }) + await pool.query(SCHEMA) +}) + +after(async () => { + if (pool) { + await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {}) + await pool.end().catch(() => {}) + } +}) + +// Checked INSIDE each test, never as a `{ skip }` option: the option is evaluated +// when the file is read, which is before `before()` has had a chance to find out +// whether there is a database. Every test skipped unconditionally is what that +// mistake looks like, and it looks exactly like a passing suite. +const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run' +const needDb = (t) => { + if (available) return false + t.skip(SKIP) + return true +} + +const T0 = new Date('2026-09-02T12:00:00Z') +const later = (ms) => new Date(T0.getTime() + ms) +const rows = (r) => Number(r.affectedRows) + +beforeEach(async () => { + if (!available) return + await pool.query('DELETE FROM event_run_steps') + await pool.query('DELETE FROM event_runs') + await pool.query('DELETE FROM event_definitions') +}) + +async function seedRun(over = {}) { + const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (?)', [ + over.graceSeconds ?? 900, + ]) + const r = await pool.query( + `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, concurrency_key, + claimed_by, claim_expires_at) + VALUES (?, 1, ?, ?, ?, ?, ?, ?)`, + [ + def.insertId, + over.scope ?? '', + over.status ?? 'scheduled', + over.scheduledFor ?? T0, + over.concurrencyKey ?? null, + over.claimedBy ?? null, + over.claimExpiresAt ?? null, + ], + ) + return { runId: r.insertId, definitionId: def.insertId } +} + +const seedStep = async (runId, over = {}) => + ( + await pool.query( + `INSERT INTO event_run_steps (run_id, phase, seq, action_id, status, due_at, attempts, + claimed_by, claim_expires_at, idempotency_key) + VALUES (?, ?, ?, 'test.noop', ?, ?, ?, ?, ?, ?)`, + [ + runId, + over.phase ?? 'main', + over.seq ?? 0, + over.status ?? 'pending', + over.dueAt ?? null, + over.attempts ?? 0, + over.claimedBy ?? null, + over.claimExpiresAt ?? null, + over.key ?? 'k'.repeat(40), + ], + ) + ).insertId + +const stepById = async (id) => (await pool.query('SELECT * FROM event_run_steps WHERE id = ?', [id]))[0] +const runById = async (id) => (await pool.query('SELECT * FROM event_runs WHERE id = ?', [id]))[0] + +// ── claimStart: exactly one winner ───────────────────────────────────────── + +test('claimStart: the first caller wins and every other gets zero', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun() + + const first = await pool.query(CLAIM_START, ['host:1', later(60_000), runId]) + const second = await pool.query(CLAIM_START, ['host:2', later(60_000), runId]) + + assert.equal(rows(first), 1, 'the winner is told 1') + assert.equal(rows(second), 0, 'the loser is told 0, not 1 with foundRows') + assert.equal((await runById(runId)).claimed_by, 'host:1') + assert.equal((await runById(runId)).status, 'starting') +}) + +test('claimStart: started_at is stamped once and never moved', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun() + await pool.query(CLAIM_START, ['host:1', later(60_000), runId]) + const first = (await runById(runId)).started_at + + await pool.query("UPDATE event_runs SET status = 'scheduled' WHERE id = ?", [runId]) + await pool.query(CLAIM_START, ['host:2', later(60_000), runId]) + + assert.deepEqual((await runById(runId)).started_at, first, 'COALESCE keeps the original instant') +}) + +// ── claimTick: a live lease refuses even its own holder ──────────────────── + +test('claimTick: a live lease is not re-enterable by the process that took it', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running', claimedBy: 'host:1', claimExpiresAt: later(60_000) }) + + const again = await pool.query(CLAIM_TICK, ['host:1', later(120_000), runId, T0]) + assert.equal(rows(again), 0, 'a tick that overran must not advance its own run twice') +}) + +test('claimTick: an expired lease is takeable, by anyone', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running', claimedBy: 'host:1', claimExpiresAt: later(-60_000) }) + + const taken = await pool.query(CLAIM_TICK, ['host:2', later(60_000), runId, T0]) + assert.equal(rows(taken), 1) + assert.equal((await runById(runId)).claimed_by, 'host:2') +}) + +test('claimTick: a paused run is never claimable', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'paused' }) + assert.equal(rows(await pool.query(CLAIM_TICK, ['host:1', later(60_000), runId, T0])), 0) +}) + +// ── transition: the guard is the whole point ─────────────────────────────── + +test('transition: a run cancelled underneath the tick is not transitioned', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + await pool.query("UPDATE event_runs SET status = 'cancelled' WHERE id = ?", [runId]) + + const moved = await pool.query(TRANSITION, ['completed', 'main', runId, 'running']) + assert.equal(rows(moved), 0) + assert.equal((await runById(runId)).status, 'cancelled') +}) + +test('transition: running -> running is a guarded write, not a no-op', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + + // This is how the runner advances `current_phase`, and `foundRows` is exactly + // what makes it report 1 despite `status` not changing — which is the answer + // the caller needs, because what it is checking is that the run is STILL + // running, not that the status moved. + const moved = await pool.query(TRANSITION, ['running', 'closing', runId, 'running']) + assert.equal(rows(moved), 1) + assert.equal((await runById(runId)).current_phase, 'closing') +}) + +// ── The step claim ───────────────────────────────────────────────────────── + +test('the step claim: one winner, and attempts is incremented by the claim alone', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + const stepId = await seedStep(runId) + + assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(60_000), stepId, T0])), 1) + assert.equal(rows(await pool.query(CLAIM_STEP, ['host:2', later(60_000), stepId, T0])), 0) + assert.equal((await stepById(stepId)).attempts, 1, 'one claim, one attempt') +}) + +test('the step claim: a step held behind a core.wait is not due', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + const stepId = await seedStep(runId, { dueAt: later(300_000) }) + + assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(60_000), stepId, T0])), 0) + assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(360_000), stepId, later(301_000)])), 1) +}) + +// ── The reclaim: order, and what it must not touch ───────────────────────── + +test('the reclaim hands a stale step back WITHOUT resetting attempts', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + const stepId = await seedStep(runId, { status: 'running', attempts: 2, claimExpiresAt: later(-1000) }) + + await pool.query(STEP_GIVE_UP, [T0, 3]) + await pool.query(STEP_RECLAIM, [T0]) + + const step = await stepById(stepId) + assert.equal(step.status, 'pending') + assert.equal(step.attempts, 2, 'Engagement Phase 14: a reclaim that reset this made MAX_ATTEMPTS unreachable') +}) + +test('the reclaim gives up FIRST, so a spent step leaves running as failed', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + const stepId = await seedStep(runId, { status: 'running', attempts: 3, claimExpiresAt: later(-1000) }) + + const gaveUp = await pool.query(STEP_GIVE_UP, [T0, 3]) + const reclaimed = await pool.query(STEP_RECLAIM, [T0]) + + assert.equal(rows(gaveUp), 1) + assert.equal(rows(reclaimed), 0, 'reversing these two makes the row retry forever') + assert.equal((await stepById(stepId)).status, 'failed') + assert.equal((await stepById(stepId)).attempts, 3) +}) + +test('the reclaim leaves a PARKED step alone, however long it waits', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + const stepId = await seedStep(runId, { status: 'running', attempts: 1, claimExpiresAt: null }) + + await pool.query(STEP_GIVE_UP, [later(365 * 24 * 3600 * 1000), 3]) + await pool.query(STEP_RECLAIM, [later(365 * 24 * 3600 * 1000)]) + + const step = await stepById(stepId) + assert.equal(step.status, 'running', 'a NULL lease is a parked cue, not staleness') + assert.equal(step.attempts, 1) +}) + +// ── holdNext ─────────────────────────────────────────────────────────────── + +test('holdNext moves the next PENDING step of the phase, and only one', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + await seedStep(runId, { seq: 0, status: 'done' }) + const second = await seedStep(runId, { seq: 1 }) + const third = await seedStep(runId, { seq: 2 }) + + const moved = await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)]) + assert.equal(rows(moved), 1) + assert.deepEqual((await stepById(second)).due_at, later(300_000)) + assert.equal((await stepById(third)).due_at, null, 'a wait holds the next step, not the rest of the phase') +}) + +test('holdNext never pulls a due date earlier, so a re-dispatch cannot double the wait', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + await seedStep(runId, { seq: 0, status: 'done' }) + const second = await seedStep(runId, { seq: 1, dueAt: later(600_000) }) + + const moved = await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)]) + assert.equal(rows(moved), 0) + assert.deepEqual((await stepById(second)).due_at, later(600_000)) +}) + +test('holdNext skips a step that is already running', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + await seedStep(runId, { seq: 0, status: 'done' }) + await seedStep(runId, { seq: 1, status: 'running' }) + const third = await seedStep(runId, { seq: 2 }) + + await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)]) + assert.deepEqual((await stepById(third)).due_at, later(300_000)) +}) + +// ── The two unique indexes that carry the weight ─────────────────────────── + +test('uq_evrun_occurrence, not the claim, is what stops two runs of one occurrence', async (t) => { + if (needDb(t)) return + const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)') + + const a = await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null]) + const b = await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null]) + + assert.equal(rows(a), 1) + assert.equal(rows(b), 0, 'INSERT IGNORE answers honestly rather than raising a 1062') + const all = await pool.query('SELECT COUNT(*) AS n FROM event_runs') + assert.equal(Number(all[0].n), 1) +}) + +test("scope '' rather than NULL is what makes that index work at all", async (t) => { + if (needDb(t)) return + const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)') + + // The empty-string case collides, as it must. A NULL scope would NOT: multiple + // NULLs do not collide in MariaDB, which would silently permit two runs of one + // occurrence — the reason the column is NOT NULL DEFAULT ''. + await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null]) + assert.equal(rows(await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])), 0) + + // Two different scopes are two different occurrences, which is what lets a + // worldwide event fan out across servers without colliding with itself. + assert.equal(rows(await pool.query(MATERIALISE_RUN, [def.insertId, 1, 'atlantic', T0, null])), 1) +}) + +test('uq_evstep_slot makes re-materialising a phase a no-op', async (t) => { + if (needDb(t)) return + const { runId } = await seedRun({ status: 'running' }) + + assert.equal(rows(await pool.query(MATERIALISE_STEP, [runId, 'main', 0, 'test.noop', 'a'.repeat(40)])), 1) + assert.equal(rows(await pool.query(MATERIALISE_STEP, [runId, 'main', 0, 'test.noop', 'b'.repeat(40)])), 0) + + const [step] = await pool.query('SELECT idempotency_key FROM event_run_steps WHERE run_id = ?', [runId]) + assert.equal(step.idempotency_key, 'a'.repeat(40), 'a re-materialise cannot overwrite a key a dispatch already sent') +}) + +// ── The grace window is per definition ───────────────────────────────────── + +test('findMissed compares against each definition’s own grace window', async (t) => { + if (needDb(t)) return + const tight = await seedRun({ graceSeconds: 600 }) + const generous = await seedRun({ graceSeconds: 3600, scope: 'b' }) + + const missed = (await pool.query(FIND_MISSED, [later(30 * 60 * 1000)])).map((r) => Number(r.id)) + assert.deepEqual(missed, [tight.runId]) + assert.ok(!missed.includes(generous.runId), 'inside its own window a run starts late rather than being missed') +}) -- 2.49.1 From 7b570c8ea1a6b269010e5a82f951e7f3aa905d11 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 08:39:35 -0500 Subject: [PATCH 03/18] feat(events): the minimal admin surface (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three screens, an Events nav group and the six live run controls Phase 1 left absent on purpose because nothing was in flight. An admin can now author, publish, start and watch an event that announces things and cues a human; a moderator can stop one that is going wrong. Six controls, not eight. `advance` is absent because a phase today advances when its steps go terminal — the per-step skip already does that — and Phase 5 is what gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`, until Phase 8's ledger exists. Every control is a compare-and-set on the status it may act from, so a console rendered thirty seconds ago cannot act on a run that has moved. Fixes a defect in the Phase 2 runner: `advanceRun` drained up to EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the tick, so a pause pressed mid-batch did nothing for up to 24 more steps. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- client/src/App.jsx | 15 + client/src/api/client.js | 39 ++ client/src/lib/eventAuthoring.js | 296 +++++++++ client/src/routes/admin/AdminLayout.jsx | 21 + client/src/routes/admin/views/EventEditor.jsx | 505 +++++++++++++++ client/src/routes/admin/views/EventRun.jsx | 352 +++++++++++ client/src/routes/admin/views/EventsAdmin.jsx | 260 ++++++++ client/test/eventAuthoring.test.js | 310 ++++++++++ server/routes.guards.json | 54 ++ server/routes.manifest.json | 24 + .../model/events/eventRunControls.model.js | 296 +++++++++ server/src/model/events/eventRunSteps.db.js | 129 ++++ server/src/model/events/eventRuns.db.js | 26 +- .../src/router/v1/admin/events.controller.js | 109 +++- server/src/router/v1/admin/events.router.js | 111 +++- server/src/utils/eventRunner.js | 8 + server/swagger/swagger-output.json | 583 ++++++++++++++++++ server/test/eventRunControls.test.js | 442 +++++++++++++ server/test/eventRunner.test.js | 50 ++ server/test/eventRunnerSql.test.js | 153 +++++ 20 files changed, 3775 insertions(+), 8 deletions(-) create mode 100644 client/src/lib/eventAuthoring.js create mode 100644 client/src/routes/admin/views/EventEditor.jsx create mode 100644 client/src/routes/admin/views/EventRun.jsx create mode 100644 client/src/routes/admin/views/EventsAdmin.jsx create mode 100644 client/test/eventAuthoring.test.js create mode 100644 server/src/model/events/eventRunControls.model.js create mode 100644 server/test/eventRunControls.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 2b0e598..aef41a0 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -49,6 +49,9 @@ import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx' import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx' import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx' import EngagementRetention from './routes/admin/views/EngagementRetention.jsx' +import EventsAdmin from './routes/admin/views/EventsAdmin.jsx' +import EventEditor from './routes/admin/views/EventEditor.jsx' +import EventRun from './routes/admin/views/EventRun.jsx' import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' @@ -192,6 +195,18 @@ export default function App() { actions that publish a game-written name is applied per request on the server, from the caller's live role (TEAMS.md 2.9). */} } /> + {/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement: + §K makes every read here `staff`, and the moderator's whole + power over this feature is the run console — cancelling a run + that is doing something wrong at 2am. The narrower gates are + applied per action instead: authoring is admin+editor, publish + and start are admin only (§N2), and each button follows the + route it calls. `runs/:runId` is declared before `:id` so the + literal segment is never read as a definition id. */} + } /> + } /> + } /> + } /> {/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the server: every route under /admin/engagement re-gates to `admin` on top of the group's staff gate, because this is the group that diff --git a/client/src/api/client.js b/client/src/api/client.js index 966adc4..a6e0a15 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -472,6 +472,45 @@ export const api = { setEngagementRetention: (body) => req('/admin/engagement/retention', { method: 'PUT', body }), + // Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring + // is admin+editor, publish and start are admin ONLY, and the six live + // controls are admin+moderator — the one gate in this feature wider than + // admin, because stopping a run at 2am is incident response and starting + // one is not (§N2). The buttons follow the same split, and the server + // re-checks every one of them. + listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`), + getEvent: (id) => req(`/admin/events/${id}`), + createEvent: (body) => req('/admin/events', { method: 'POST', body }), + updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }), + publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }), + archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }), + listEventVersions: (id) => req(`/admin/events/${id}/versions`), + eventCatalog: () => req('/admin/events/catalog'), + eventSeries: () => req('/admin/events/series'), + startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }), + listEventRuns: ({ definitionId, status, limit } = {}) => { + const qs = new URLSearchParams() + if (definitionId) qs.set('definitionId', String(definitionId)) + if (status) qs.set('status', status) + if (limit) qs.set('limit', String(limit)) + const suffix = qs.toString() + return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`) + }, + getEventRun: (runId) => req(`/admin/events/runs/${runId}`), + getEventRunLog: (runId, limit) => + req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`), + pauseEventRun: (runId, reason) => + req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }), + resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }), + cancelEventRun: (runId, reason) => + req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }), + confirmEventStep: (runId, stepId, note) => + req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }), + skipEventStep: (runId, stepId, reason) => + req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }), + retryEventStep: (runId, stepId) => + req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }), + // Teams (docs/website/TEAMS.md §2.11). Three of these mean something // different depending on who calls them: for a moderator, unhide and // setTeamDisplayName file a request and the response says `pending: true`. diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js new file mode 100644 index 0000000..8ce3179 --- /dev/null +++ b/client/src/lib/eventAuthoring.js @@ -0,0 +1,296 @@ +// ── What the three Events screens say, and what they let staff press ─────── +// +// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server +// decides what may be saved, and the six control statements decide what may +// happen to a run — every one of them is a compare-and-set that re-checks the +// status this file only *predicted*. What is here is the part that would be +// wrong silently: a form that drops an authored step, a params box that posts a +// string where the action declared an int, and above all a console that offers a +// button the server is going to refuse. +// +// **The controls are modelled here rather than inline in the console for one +// reason: they can be tested against the server's rules.** A button that 409s is +// not a bug the way a wrong write is, but it is the failure mode an operator +// meets at 2am while the thing they are trying to stop keeps running — so the +// guards are written twice on purpose and the copy is checked. + +// A run that is over. Verbatim `eventRuns.db`'s TERMINAL. +export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed'] + +export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status) + +/** A step waiting on a human: `running`, with nothing holding it. */ +export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked) + +/** + * The highest `seq` of a step in this phase that is not still `pending` — the + * furthest the phase has got — or null when none of it has been attempted. + * + * The same rule as the server's `lastStartedSeq`, over the step list the console + * already has, and used only to decide whether to OFFER retry. The near miss is + * worth keeping in view: "the lowest step that is not finished" looks like the + * same thing and is not, because the runner steps OVER a failed step. Under that + * rule a phase that carried on past an `on_failure: skip` failure and then paused + * at a later one would offer retry on the wrong step. + */ +export function lastStartedSeqOf(steps, phase) { + const started = (steps || []) + .filter((s) => s.phase === phase && s.status !== 'pending') + .map((s) => Number(s.seq)) + return started.length ? Math.max(...started) : null +} + +/** + * Which run-level controls to offer. + * + * `pause` is `starting`/`running` only: a `scheduled` occurrence that should not + * happen is cancelled, not paused. `cancel` is everything non-terminal — "this + * is not happening" is a decision made before a run starts as often as during + * one. + */ +export function runControlsFor(run) { + if (!run) return { pause: false, resume: false, cancel: false } + const terminal = isTerminalRun(run.status) + return { + pause: ['starting', 'running'].includes(run.status), + resume: run.status === 'paused', + cancel: !terminal, + } +} + +/** + * Which step-level controls to offer, for one step of one run. + * + * `retry` carries the guard worth restating: only while the run is PAUSED, only + * on a `failed` step of the phase the run is currently in, and only when that + * step is the furthest one the phase has reached. A failed step under an + * `on_failure` of `skip` is one the run has already moved past, and re-queueing + * it would put a pending row behind the runner's cursor, where it would sit for + * ever. + */ +export function stepControlsFor(run, step, steps) { + const none = { confirm: false, skip: false, retry: false } + if (!run || !step) return none + if (isTerminalRun(run.status)) return none + + const parked = isParked(step) + const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null + + return { + confirm: parked, + skip: parked || step.status === 'pending', + retry: + run.status === 'paused' && + step.status === 'failed' && + step.phase === run.currentPhase && + furthest !== null && + Number(furthest) === Number(step.seq), + } +} + +// ── The definition form ──────────────────────────────────────────────────── + +export const BLANK_PHASE_KEY = 'phase' + +const nextPhaseKey = (phases) => { + const used = new Set((phases || []).map((p) => p.key)) + for (let n = 1; n < 100; n++) { + const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}` + if (!used.has(key)) return key + } + return `${BLANK_PHASE_KEY}-${Date.now()}` +} + +/** + * A new step, with its params box PREFILLED from the action's declared examples. + * + * Every param carries a required `example` — that requirement is the reason this + * works — so a fresh `core.announce` step arrives as a JSON object with the right + * keys and plausible values rather than as an empty `{}` an author has to guess + * the shape of. It is the nearest a raw JSON box gets to the schema-driven form + * Phase 13 replaces it with, and it costs nothing the catalog was not already + * serving. + */ +export function blankStep(action) { + const params = {} + for (const p of action?.params || []) { + if (p.required || p.example !== undefined) params[p.name] = p.example + } + return { + actionId: action?.id || '', + label: action?.label || '', + onFailure: '', + paramsText: JSON.stringify(params, null, 2), + } +} + +export function blankPhase(phases) { + return { key: nextPhaseKey(phases), label: 'New phase', steps: [] } +} + +/** The editor's working state, from what `GET /admin/events/:id` returned. */ +export function formFromDefinition(event) { + const spec = event?.spec || {} + return { + title: event?.title || '', + summary: event?.summary || '', + body: event?.body || '', + imageUrl: event?.imageUrl || '', + seriesId: event?.seriesId ? String(event.seriesId) : '', + seriesOrder: event?.seriesOrder ?? 0, + concurrencyKey: event?.concurrencyKey || '', + graceSeconds: event?.graceSeconds ?? 900, + timezone: event?.timezone || 'UTC', + scheduleKind: spec.schedule?.kind || 'manual', + phases: (spec.phases || []).map((p) => ({ + key: p.key || '', + label: p.label || '', + steps: (p.steps || []).map((s) => ({ + actionId: s.actionId || '', + label: s.label || '', + onFailure: s.onFailure || '', + dormant: Boolean(s.dormant), + actionVersion: s.actionVersion, + paramsText: JSON.stringify(s.params || {}, null, 2), + })), + })), + } +} + +/** + * The form, as a request body — or the list of everything wrong with it. + * + * Only the JSON parse is checked here, and only because a params box whose text + * is not JSON cannot be turned into a request at all. **Everything else is left + * to the server**: unknown params, wrong types, missing required ones, bad phase + * keys and duplicate keys all come back from `POST`/`PUT` as a list, and + * re-deciding any of them here would be a second validator drifting from the one + * that matters. + * + * `onFailure` is omitted when the author has not chosen one, so the server + * applies the action's risk-class default rather than being told a value the + * form invented. + */ +export function payloadFromForm(form) { + const errors = [] + const phases = (form.phases || []).map((phase, pi) => ({ + key: phase.key, + label: phase.label, + steps: (phase.steps || []).map((step, si) => { + const out = { actionId: step.actionId } + if (step.label) out.label = step.label + if (step.onFailure) out.onFailure = step.onFailure + const parsed = parseParams(step.paramsText) + if (parsed.error) { + errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`) + } else { + out.params = parsed.params + } + return out + }), + })) + + if (errors.length) return { ok: false, errors } + + return { + ok: true, + payload: { + title: form.title, + summary: form.summary || null, + body: form.body || null, + imageUrl: form.imageUrl || null, + seriesId: form.seriesId ? Number(form.seriesId) : null, + seriesOrder: Number(form.seriesOrder) || 0, + concurrencyKey: form.concurrencyKey || null, + graceSeconds: Number(form.graceSeconds), + timezone: form.timezone, + spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases }, + }, + } +} + +/** An empty box is `{}`, not a parse error — a step may legitimately take none. */ +export function parseParams(text) { + const raw = (text || '').trim() + if (!raw) return { params: {} } + let value + try { + value = JSON.parse(raw) + } catch (err) { + return { error: `the params are not valid JSON (${err.message})` } + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { error: 'the params must be a JSON object' } + } + return { params: value } +} + +// ── Rendering what happened ──────────────────────────────────────────────── + +const STATUS_WORDS = { + scheduled: 'Scheduled', + starting: 'Starting', + running: 'Running', + paused: 'Paused', + ending: 'Winding down', + completed: 'Completed', + cancelled: 'Cancelled', + failed: 'Failed', + missed: 'Missed', +} + +export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown' + +const KIND_WORDS = { + 'run.created': 'Occurrence created', + 'run.status': 'Run status', + 'run.health': 'Health', + 'run.blocked': 'Held off', + 'phase.entered': 'Phase entered', + 'phase.completed': 'Phase completed', + 'step.status': 'Step', + 'step.retry': 'Step retried', + 'step.parked': 'Waiting on a human', + note: 'Note', +} + +export const logKindWord = (kind) => KIND_WORDS[kind] || kind + +/** + * One log line as a sentence. + * + * The `detail` of a human control carries `control` and `by`, which is what + * separates "the runner paused this because a world write failed" from "somebody + * pressed pause" — the two are the same transition and the console has to be + * able to tell them apart at a glance. + */ +export function describeLogLine(line) { + const d = line?.detail || {} + const by = d.by ? ' by staff' : '' + switch (line?.kind) { + case 'run.status': + return d.control + ? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}` + : `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}` + case 'run.health': + return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}` + case 'run.blocked': + return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"` + case 'phase.entered': + return `Entered ${line.phase} (${d.steps ?? '?'} steps)` + case 'phase.completed': + return `${line.phase} finished` + case 'step.parked': + return `${d.action} is waiting on a human` + case 'step.retry': + return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}` + case 'step.status': + return d.control + ? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}` + : `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}` + case 'run.created': + return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}` + default: + return logKindWord(line?.kind) + } +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 3010eac..d489c5e 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -53,6 +53,7 @@ const IconList = () => const IconSpark = () => const IconLog = () => +const IconCalendar = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -120,6 +121,20 @@ export const NAV = [ { to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] }, ], }, + { + // Its own top-level group rather than a row under Content, and staff-wide + // rather than admin-only. Both follow EVENTS.md §K: every read here is + // `staff`, and the moderator's entire power over this feature is the run + // console — the thing they open when an event is doing something wrong at + // 2am. Hiding it from them would leave the one role that exists for incident + // response unable to see the incident. The narrower gates live on the + // actions: authoring is admin+editor and publish/start are admin only, both + // enforced server-side and mirrored on the buttons. + title: 'Events', + items: [ + { to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] }, + ], + }, { title: 'System', items: [ @@ -202,6 +217,8 @@ const TITLES = { '/admin/engagement/suppressions': 'Suppressions', '/admin/engagement/sends': 'Send Log', '/admin/engagement/retention': 'Retention', + '/admin/events': 'Events', + '/admin/events/new': 'New event', } // An installed module's admin pages are not in TITLES and cannot be — core does @@ -222,6 +239,10 @@ function sectionTitle(pathname) { if (pathname.startsWith('/admin/moderation')) return 'Moderation' if (pathname.startsWith('/admin/users/')) return 'User' if (pathname.startsWith('/admin/engagement')) return 'Engagement' + // /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both + // belong to the same section as far as the page title is concerned. + if (pathname.startsWith('/admin/events/runs/')) return 'Event run' + if (pathname.startsWith('/admin/events/')) return 'Event' return 'Admin' } diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx new file mode 100644 index 0000000..3ab4d99 --- /dev/null +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -0,0 +1,505 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAuth } from '../../../contexts/AuthContext.jsx' +import { api } from '../../../api/client.js' +import { + formFromDefinition, + payloadFromForm, + blankPhase, + blankStep, +} from '../../../lib/eventAuthoring.js' + +// Admin → Events → the definition editor (EVENTS.md §I, Phase 3). +// +// **A vertical timeline, not a node graph**, and that is a decision about what +// the engine can actually do rather than a matter of taste. The condition +// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at +// depth five — so a canvas would promise power this project has never handed an +// operator. Phases in order, each with its steps in order, says exactly what the +// runner does with them. +// +// **Core renders no game word here.** Every label on a step comes from the +// action's own registration — its `label`, its params' names, their descriptions +// and their examples — so an installed module's vocabulary appears without core +// knowing any of it, and `check:modules` already fails core's build on a UO +// identifier. +// +// **The params box is a raw JSON field and it is captioned as a placeholder**, +// because that is what it is: Phase 13 replaces it with the schema-driven form +// the condition builder already models. What makes it usable in the meantime is +// that a new step arrives PREFILLED from the action's declared examples, and the +// declaration is rendered beside the box — every param's name, type, whether it +// is required, and what a value looks like. All of that was already in the +// catalog; none of it is a second copy of anything. + +const DORMANT_NOTE = + 'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.' + +export default function EventEditor() { + const { id } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const isNew = id === 'new' + + const [form, setForm] = useState(null) + const [event, setEvent] = useState(null) + const [catalog, setCatalog] = useState(null) + const [series, setSeries] = useState([]) + const [versions, setVersions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [problems, setProblems] = useState([]) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(false) + + const isAdmin = user?.role === 'admin' + // Reads here are staff-wide (§K), so a moderator reaches this screen legitimately + // — the run console is their whole job and a definition is what a run is OF. But + // authoring is `admin` + `editor`, so Save has to follow the route it calls. + // Offering it and letting the server answer 403 is the shape §K calls "a gate + // nobody notices was missing", only inverted: a button that does nothing. + const mayAuthor = isAdmin || user?.role === 'editor' + + const load = useCallback(async () => { + const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()]) + setCatalog(cat) + setSeries(ser.series || []) + if (isNew) { + setEvent(null) + setVersions([]) + setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } })) + return + } + const [{ event: loaded }, { versions: history }] = await Promise.all([ + api.admin.getEvent(id), + api.admin.listEventVersions(id), + ]) + setEvent(loaded) + setVersions(history || []) + setForm(formFromDefinition(loaded)) + }, [id, isNew]) + + useEffect(() => { + let alive = true + ;(async () => { + setLoading(true) + try { + await load() + if (alive) setError(null) + } catch (err) { + if (alive) setError(err.message) + } finally { + if (alive) setLoading(false) + } + })() + return () => { + alive = false + } + }, [load]) + + const actions = useMemo(() => catalog?.actions || [], [catalog]) + const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions]) + + const set = (patch) => setForm((f) => ({ ...f, ...patch })) + + const setPhase = (pi, patch) => + setForm((f) => ({ + ...f, + phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)), + })) + + const setStep = (pi, si, patch) => + setForm((f) => ({ + ...f, + phases: f.phases.map((p, i) => + i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p, + ), + })) + + const movePhase = (pi, delta) => + setForm((f) => { + const next = [...f.phases] + const to = pi + delta + if (to < 0 || to >= next.length) return f + ;[next[pi], next[to]] = [next[to], next[pi]] + return { ...f, phases: next } + }) + + const moveStep = (pi, si, delta) => + setForm((f) => ({ + ...f, + phases: f.phases.map((p, i) => { + if (i !== pi) return p + const steps = [...p.steps] + const to = si + delta + if (to < 0 || to >= steps.length) return p + ;[steps[si], steps[to]] = [steps[to], steps[si]] + return { ...p, steps } + }), + })) + + /** + * Changing a step's action REPLACES its params with the new action's examples. + * + * The alternative — keeping what was typed — leaves an object whose keys belong + * to a different action, and the save refuses it with "x is not a param of y" + * for every one of them. Replacing is the honest move and it is not + * destructive in any way an author minds: they have just said this step does + * something else. + */ + const changeAction = (pi, si, actionId) => { + const action = actionById.get(actionId) + const fresh = blankStep(action) + setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' }) + } + + const save = async () => { + setBusy(true) + setProblems([]) + setNotice(null) + const built = payloadFromForm(form) + if (!built.ok) { + setProblems(built.errors) + setBusy(false) + return + } + try { + if (isNew) { + const result = await api.admin.createEvent(built.payload) + navigate(`/admin/events/${result.event.id}`, { replace: true }) + } else { + const result = await api.admin.updateEvent(id, built.payload) + setEvent(result.event) + setForm(formFromDefinition(result.event)) + setNotice('Saved.') + } + } catch (err) { + // The server answers with every problem rather than the first, so an author + // fixing a spec does it in one pass rather than six round trips. + setProblems(err.body?.errors || [err.message]) + } finally { + setBusy(false) + } + } + + const publish = async () => { + setBusy(true) + setProblems([]) + setNotice(null) + try { + const result = await api.admin.publishEvent(id) + setEvent(result.event) + setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || [])) + setNotice(`Published as v${result.version}.`) + } catch (err) { + setProblems(err.body?.errors || [err.message]) + } finally { + setBusy(false) + } + } + + const start = async () => { + setBusy(true) + setProblems([]) + try { + const result = await api.admin.startEventRun(id, {}) + navigate(`/admin/events/runs/${result.run.id}`) + } catch (err) { + setProblems(err.body?.errors || [err.message]) + } finally { + setBusy(false) + } + } + + if (loading || !form) return + if (error) return + + const archived = event?.state === 'archived' + + return ( +
+
+
+

+ {isNew ? 'New event' : event?.title} +

+

+ {isNew ? ( + 'The slug is derived from the title once and frozen afterwards — the public event page lives at it.' + ) : ( + <> + {event?.slug} · {event?.state} + {event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'} + + )} +

+
+
+ {mayAuthor && ( + + )} + {/* Publish and start are admin ONLY (§N2) and not the same gate as the + live controls: publishing commits a definition a schedule will later + start unattended. */} + {!isNew && isAdmin && ( + + )} + {!isNew && isAdmin && event?.state === 'ready' && ( + + )} +
+
+ + {!mayAuthor && ( +

+ You can read this definition and watch its runs. Editing and publishing an event are an + admin or editor's, and starting one is an admin's alone — live control of a run already in + flight is yours. +

+ )} + + {archived && ( +

+ This definition is archived. It is kept so its past runs can still be explained, and it + cannot be edited or run again. +

+ )} + + {notice &&

{notice}

} + + {problems.length > 0 && ( +
+

That did not save:

+
    + {problems.map((p) =>
  • {p}
  • )} +
+
+ )} + + {/* ── Basics ── */} +
+
+ + + + + +
+ +