feat(events): the minimal admin surface (Phase 3) #185

Merged
whitlocktech merged 1 commits from feat/events-phase-3 into edge 2026-09-02 15:47:33 +00:00
Member

Event System Phase 3 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#211.

An admin can now author, publish, start and watch an event that announces things and cues a human — and a moderator can stop one that is going wrong. This is the first phase with a demo, and it has been walked end to end on the local review stack (details at the bottom).

Three screens, an Events nav group, and the six live run controls Phase 1 left absent on purpose because nothing was in flight and Phase 2 gave something to act on.

  1. Six controls, not four and not eight. pause, resume, cancel on a run; confirm, skip, retry on a step — all admin + moderator. advance is not built: a phase today advances when its steps go terminal and the per-step skip already does that one step at a time, so a force-advance now would silently change meaning under the operator when Phase 5 gives a phase an advance condition. Cancel takes { reason }, not { cleanup, reason } — the flag arrives with Phase 8's ledger, and a cleanup: false that changes nothing is the "control that answers 200 and does nothing" Phases 1 and 2 both refused.
  2. Its own top-level nav group, staff-wide (admin, editor, moderator) rather than admin-only like Engagement. §K makes every read here staff, and the moderator's entire power over this feature is the run console — hiding it would leave the one role that exists for incident response unable to see the incident. The narrow gates are on the actions instead, and each button follows the route it calls.
  3. The params box is a raw JSON field with the action's declaration rendered beside it — name, type, required, description, example, all already in the catalog. A new step arrives prefilled from the declared examples. Captioned as a placeholder so it does not read as Phase 13's schema-driven form.
  4. Two smaller ones taken as assumptions rather than asked: the console polls every 5s while the run is non-terminal and stops the moment it is not (§N5 — a run changes on a fifteen-second tick and a console is a tab left open for two hours), and confirm takes an optional note saying what was actually done in-client.

Three things the build settled — the first is a defect in shipped code

  • A pause pressed mid-tick did nothing for up to 24 more steps. advanceRun drains up to EVENT_STEPS_PER_TICK steps from one run inside a single tick and only checked the run's status at the top of it — so the entire value of a pause, that it takes effect now, was absent. The loop now re-reads the status between steps (runsDb.statusOf, one indexed column by primary key). Found by writing the test, and the test was re-run against the unfixed code to confirm it fails — it does.
  • The retry guard was reading the wrong end of the phase. The first draft asked for the lowest seq that is not settled, which looks equivalent to "the step the run is stopped at" and is not: nextOpenStep selects pending and running only, so the runner steps over a failed step. A phase whose second step failed-and-skipped and whose fifth then failed-and-paused would have offered retry on the second, re-queueing a row behind the runner's own cursor where it sits pending for ever. The rule is now MAX(seq) WHERE status <> 'pending' — the furthest the phase has reached. The test that found it names the case, and the live walk proves it (below).
  • Retry and resume are one control, because there is no state in which you would want half of it: retry is legal only from paused, and a paused run is paused at that step. Splitting them would read as honesty and behave as a trap. attempts returns to zero — the ceiling bounds what the runner does unattended, and a named person deciding once is the thing it is unattended from. That is not Engagement Phase 14's rule being broken; that rule is about automatic sweeps.

The controls, and why they are compare-and-set

The runner ticks every fifteen seconds, so a console rendered thirty seconds ago describes a run that has since moved. Every control is guarded in its WHERE clause rather than by a read-then-write, and a refusal is a 409 naming the status the run is actually in. client/src/lib/eventAuthoring.js models the same guards so a button the server will refuse is not offered in the first place — the copy is deliberate, and eventAuthoring.test.js is where it is checked against the original.

Two derived response fields arrived with the console, both derived rather than columns: a run's waitingSteps (parked on a human) so the run list can say so, and a step's parked boolean so the console can tell a cue waiting on a person from a step some process is mid-dispatch on — without being shown claimed_by/claim_expires_at, which are the runner's business.

A cue nobody notices is a run that never advances, and it looks perfectly healthy from outside: running, nothing failed. So the list leads with a banner naming every run waiting on a person.

Verify

  • npm test1714 tests, 1662 pass, 51 skipped, 1 fail. The one failure is engagementManifest.test.js, pre-existing and environmental (CRLF under core.autocrlf=true); confirmed by stashing this branch and re-running: edge is 1682/1638/43/1, the same single failure.
  • 42 new tests. eventRunControls.test.js (22, almost all refusals — a control that works from a status it should not is a staff member changing a live world from a stale screen); 8 more in eventRunnerSql.test.js proving the four new statements against a real MariaDB (27/27 with a database, skipped without); 2 in eventRunner.test.js; eventAuthoring.test.js (20) on the client.
  • npm run routes:manifest and npm run swaggersix routes added, none moved. check:modules and check:hosts clean. The client builds.

The live walk, on the local review stack

Server + Vite against the uomm-db container, five runs, as navadmin and navmod:

  • A run announced, waited, parked on a cue and held across four ticks — nothing reclaimed it — then completed after the confirm.
  • §N2's split, live: navmod got 403 on publish and 403 on start, and 200 on confirm.
  • Pause → 409 on a scheduled run; pause on a running one; skip on a step behind a ten-minute wait let the run finish at once; resume cleared last_error and left health: degraded.
  • Retry & resume on a paused run: attempts back to 0, run resumed, step re-dispatched, failed again and paused again — the honest loop.
  • The retry guard, live: on a phase that had moved past a failed step, retry on seq 0 was 409 ("the run is not stopped at this step") and retry on seq 2 was 200. The run console offers the button on seq 2 only.
  • Cancel on a run parked on a cue: the parked step went cancelled and waitingSteps dropped to 0 — a cancelled run stops claiming to wait on somebody.
  • The dashboard activity log shows every control with its actor (§J).

Two defects the walk found in this branch, both fixed here: the degraded banner said "It is still running" on a paused run, and the editor offered Save to a moderator, which the server 403s.


  • AI-assisted: authored with Claude Code (Claude Opus).
Event System **Phase 3** (`EVENTS_PLAN.md`). Docs half: **RunicGateway/docs#211**. > **An admin can now author, publish, start and watch an event that announces things and cues a human — and a moderator can stop one that is going wrong.** This is the first phase with a demo, and it has been walked end to end on the local review stack (details at the bottom). Three screens, an **Events** nav group, and the **six live run controls** Phase 1 left absent on purpose because nothing was in flight and Phase 2 gave something to act on. ## Four decisions, settled 2026-09-02, all as recommended 1. **Six controls, not four and not eight.** `pause`, `resume`, `cancel` on a run; `confirm`, `skip`, `retry` on a step — all `admin` + `moderator`. **`advance` is not built**: a phase today advances when its steps go terminal and the per-step skip already does that one step at a time, so a force-advance now would silently change meaning under the operator when Phase 5 gives a phase an advance *condition*. **Cancel takes `{ reason }`, not `{ cleanup, reason }`** — the flag arrives with Phase 8's ledger, and a `cleanup: false` that changes nothing is the "control that answers 200 and does nothing" Phases 1 and 2 both refused. 2. **Its own top-level nav group, staff-wide** (`admin`, `editor`, `moderator`) rather than admin-only like Engagement. §K makes every read here `staff`, and the moderator's entire power over this feature is the run console — hiding it would leave the one role that exists for incident response unable to see the incident. The narrow gates are on the actions instead, and each button follows the route it calls. 3. **The params box is a raw JSON field with the action's declaration rendered beside it** — name, type, required, description, example, all already in the catalog. A new step arrives **prefilled from the declared examples**. Captioned as a placeholder so it does not read as Phase 13's schema-driven form. 4. Two smaller ones taken as assumptions rather than asked: the console **polls every 5s while the run is non-terminal and stops the moment it is not** (§N5 — a run changes on a fifteen-second tick and a console is a tab left open for two hours), and `confirm` takes an optional note saying what was actually done in-client. ## Three things the build settled — the first is a defect in shipped code - **A pause pressed mid-tick did nothing for up to 24 more steps.** `advanceRun` drains up to `EVENT_STEPS_PER_TICK` steps from one run inside a single tick and only checked the run's status at the top of it — so the entire value of a pause, that it takes effect *now*, was absent. The loop now re-reads the status between steps (`runsDb.statusOf`, one indexed column by primary key). **Found by writing the test, and the test was re-run against the unfixed code to confirm it fails** — it does. - **The retry guard was reading the wrong end of the phase.** The first draft asked for the lowest `seq` that is not *settled*, which looks equivalent to "the step the run is stopped at" and is not: `nextOpenStep` selects `pending` and `running` only, so the runner steps **over** a `failed` step. A phase whose second step failed-and-skipped and whose fifth then failed-and-paused would have offered retry on the second, re-queueing a row **behind the runner's own cursor** where it sits `pending` for ever. The rule is now `MAX(seq) WHERE status <> 'pending'` — the furthest the phase has reached. The test that found it names the case, and the live walk proves it (below). - **Retry and resume are one control**, because there is no state in which you would want half of it: retry is legal only from `paused`, and a paused run is paused *at* that step. Splitting them would read as honesty and behave as a trap. `attempts` returns to zero — the ceiling bounds what the runner does **unattended**, and a named person deciding once is the thing it is unattended from. That is not Engagement Phase 14's rule being broken; that rule is about automatic *sweeps*. ## The controls, and why they are compare-and-set The runner ticks every fifteen seconds, so a console rendered thirty seconds ago describes a run that has since moved. Every control is guarded in its `WHERE` clause rather than by a read-then-write, and a refusal is a `409` naming the status the run is actually in. `client/src/lib/eventAuthoring.js` models the same guards so a button the server will refuse is not offered in the first place — the copy is deliberate, and `eventAuthoring.test.js` is where it is checked against the original. Two derived response fields arrived with the console, both **derived rather than columns**: a run's `waitingSteps` (parked on a human) so the run **list** can say so, and a step's `parked` boolean so the console can tell a cue waiting on a person from a step some process is mid-dispatch on — without being shown `claimed_by`/`claim_expires_at`, which are the runner's business. **A cue nobody notices is a run that never advances**, and it looks perfectly healthy from outside: `running`, nothing failed. So the list leads with a banner naming every run waiting on a person. ## Verify - `npm test` — **1714 tests, 1662 pass, 51 skipped, 1 fail.** The one failure is `engagementManifest.test.js`, pre-existing and environmental (CRLF under `core.autocrlf=true`); **confirmed by stashing this branch and re-running: `edge` is 1682/1638/43/1, the same single failure.** - **42 new tests.** `eventRunControls.test.js` (22, almost all *refusals* — a control that works from a status it should not is a staff member changing a live world from a stale screen); 8 more in `eventRunnerSql.test.js` proving the four new statements **against a real MariaDB** (27/27 with a database, skipped without); 2 in `eventRunner.test.js`; `eventAuthoring.test.js` (20) on the client. - `npm run routes:manifest` and `npm run swagger` — **six routes added, none moved.** `check:modules` and `check:hosts` clean. The client builds. ### The live walk, on the local review stack Server + Vite against the `uomm-db` container, five runs, as `navadmin` and `navmod`: - A run **announced, waited, parked on a cue and held across four ticks** — nothing reclaimed it — then completed after the confirm. - **§N2's split, live:** `navmod` got **403 on publish and 403 on start**, and **200 on confirm**. - **Pause → 409 on a `scheduled` run**; pause on a running one; **skip** on a step behind a ten-minute wait let the run finish at once; **resume** cleared `last_error` and left `health: degraded`. - **Retry & resume** on a paused run: attempts back to 0, run resumed, step re-dispatched, failed again and paused again — the honest loop. - **The retry guard, live:** on a phase that had moved past a failed step, retry on `seq 0` was **409** ("the run is not stopped at this step") and retry on `seq 2` was **200**. The run console offers the button on seq 2 only. - **Cancel** on a run parked on a cue: the parked step went `cancelled` and `waitingSteps` dropped to 0 — a cancelled run stops claiming to wait on somebody. - The dashboard activity log shows every control with its actor (§J). **Two defects the walk found in this branch, both fixed here:** the degraded banner said *"It is still running"* on a **paused** run, and the editor offered **Save** to a moderator, which the server 403s. --- - [x] AI-assisted: authored with Claude Code (Claude Opus).
wtclaude added 1 commit 2026-09-02 13:40:38 +00:00
feat(events): the minimal admin surface (Phase 3)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 5m26s
PR Checks / client-build (pull_request) Successful in 8m30s
7b570c8ea1
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
whitlocktech merged commit a481248bc0 into edge 2026-09-02 15:47:33 +00:00
whitlocktech deleted branch feat/events-phase-3 2026-09-02 15:47:34 +00:00
Sign in to join this conversation.
No description provided.