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

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
This commit is contained in:
2026-09-02 08:39:35 -05:00
parent 2ba397eff7
commit 7b570c8ea1
20 changed files with 3775 additions and 8 deletions

View File

@@ -25,6 +25,19 @@
// both a MariaDB-specific syntax and a correctness claim: it must move the
// next PENDING step and only ever push a due date later.
//
// **Phase 3 added four more**, and each of them is a control a staff member
// presses against a live game world:
//
// * **`confirmParked` / `skipByHuman`** - both keyed on `status = 'running'
// AND claim_expires_at IS NULL`. That pair, and only that pair, means "a cue
// waiting on a human". If the clause let a LEASED step through, confirm would
// race the process mid-dispatch on that row.
// * **`cancelOpen`** - pending steps and parked cues, never a leased one.
// * **`lastStartedSeq`** - a `MAX(seq) ... WHERE status <> 'pending'`, which is
// what decides whether retry is offered. The first draft asked for the LOWEST
// unsettled seq instead, which is a different step whenever a phase carried
// on past an `on_failure: skip` failure.
//
// 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
@@ -150,6 +163,36 @@ UPDATE event_run_steps
AND (due_at IS NULL OR due_at < ?)
ORDER BY seq LIMIT 1`
// Phase 3's four, verbatim from `eventRunSteps.db.js`.
const CONFIRM_PARKED = `
UPDATE event_run_steps
SET status = 'done', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`
const SKIP_BY_HUMAN = `
UPDATE event_run_steps
SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
const REQUEUE = `
UPDATE event_run_steps
SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
WHERE id = ? AND status = 'failed'`
const CANCEL_OPEN = `
UPDATE event_run_steps
SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
WHERE run_id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
const LAST_STARTED_SEQ = `
SELECT MAX(seq) AS seq FROM event_run_steps
WHERE run_id = ? AND phase = ? AND status <> 'pending'`
const MATERIALISE_RUN = `
INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
VALUES (?, ?, ?, ?, ?)`
@@ -506,3 +549,113 @@ test('findMissed compares against each definitions own grace window', async (
assert.deepEqual(missed, [tight.runId])
assert.ok(!missed.includes(generous.runId), 'inside its own window a run starts late rather than being missed')
})
// -- Phase 3: the controls a human presses ----------------------------------
test('confirm resolves a parked cue and cannot touch a step being dispatched', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const parked = await seedStep(runId, { seq: 0, status: 'running', claimedBy: 'host:1', claimExpiresAt: null })
const busy = await seedStep(runId, { seq: 1, status: 'running', claimedBy: 'host:1', claimExpiresAt: later(60_000), key: 'x'.repeat(40) })
assert.equal(rows(await pool.query(CONFIRM_PARKED, ['gate opened', parked])), 1)
assert.equal(rows(await pool.query(CONFIRM_PARKED, ['nope', busy])), 0, 'a live lease is a step somebody owns')
assert.equal((await stepById(parked)).status, 'done')
assert.equal((await stepById(parked)).last_error, 'gate opened')
assert.equal((await stepById(busy)).status, 'running')
})
test('a confirm of an already-confirmed cue reports 0, not 1', async (t) => {
if (needDb(t)) return
// The engagement Phase 4a shape: a connector that defaults `foundRows: true`
// reports 1 for an UPDATE that matched and changed nothing, and a control that
// read that as success would tell a second staff member their press worked.
const { runId } = await seedRun({ status: 'running' })
const parked = await seedStep(runId, { status: 'running', claimExpiresAt: null })
assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 1)
assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 0)
})
test('skip takes a pending step and a parked cue, and refuses a leased one', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const pending = await seedStep(runId, { seq: 0, status: 'pending' })
const parked = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: null, key: 'y'.repeat(40) })
const busy = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: later(60_000), key: 'z'.repeat(40) })
const failed = await seedStep(runId, { seq: 3, status: 'failed', key: 'w'.repeat(40) })
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, pending])), 1)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, parked])), 1)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, busy])), 0)
assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, failed])), 0, 'a failed step is terminal; resume carries the phase past it')
})
test('cancelOpen closes pending steps and parked cues, and leaves a leased one alone', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
const done = await seedStep(runId, { seq: 0, status: 'done' })
const busy = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: later(60_000), key: 'p'.repeat(40) })
const parked = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: null, key: 'q'.repeat(40) })
const pending = await seedStep(runId, { seq: 3, status: 'pending', key: 'r'.repeat(40) })
assert.equal(rows(await pool.query(CANCEL_OPEN, [runId])), 2)
assert.equal((await stepById(done)).status, 'done')
assert.equal((await stepById(busy)).status, 'running', 'nothing can recall a command already sent')
assert.equal((await stepById(parked)).status, 'cancelled', 'a cancelled run must stop claiming to wait on somebody')
assert.equal((await stepById(pending)).status, 'cancelled')
})
test('requeue only takes a failed step, and puts attempts back to zero', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'paused' })
const failed = await seedStep(runId, { seq: 0, status: 'failed', attempts: 3 })
const pending = await seedStep(runId, { seq: 1, status: 'pending', key: 's'.repeat(40) })
assert.equal(rows(await pool.query(REQUEUE, [failed])), 1)
assert.equal(rows(await pool.query(REQUEUE, [pending])), 0)
const row = await stepById(failed)
assert.equal(row.status, 'pending')
assert.equal(Number(row.attempts), 0)
assert.equal(row.due_at, null, 'a re-queued step is due now, not at the retry backoff it was left on')
})
test('lastStartedSeq names the furthest step of the phase, not the earliest unsettled one', async (t) => {
if (needDb(t)) return
// The defect this replaced: a phase that carried on past a failed step (an
// `on_failure` of `skip`) and then paused at a later one. "The lowest seq that
// is not settled" answers with the FIRST failure - a step the runner has long
// since stepped over - and retry would re-queue a row behind its own cursor.
const { runId } = await seedRun({ status: 'paused' })
await seedStep(runId, { seq: 0, status: 'failed', key: 'a'.repeat(40) })
await seedStep(runId, { seq: 1, status: 'done', key: 'b'.repeat(40) })
await seedStep(runId, { seq: 2, status: 'failed', key: 'c'.repeat(40) })
await seedStep(runId, { seq: 3, status: 'pending', key: 'd'.repeat(40) })
const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
assert.equal(Number(row.seq), 2)
})
test('lastStartedSeq is NULL for a phase nothing has touched', async (t) => {
if (needDb(t)) return
const { runId } = await seedRun({ status: 'running' })
await seedStep(runId, { seq: 0, status: 'pending' })
const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
assert.equal(row.seq, null, 'a null must read as "nothing to retry", not as seq 0')
})
test('a guarded transition refuses a run that was cancelled underneath it', async (t) => {
if (needDb(t)) return
// What the admin cancel looks like from the runner's side, mid-tick: the
// guarded write returns 0 and the tick treats the run as taken rather than
// advancing a run somebody has just stopped.
const { runId } = await seedRun({ status: 'running' })
await pool.query("UPDATE event_runs SET status = 'cancelled' WHERE id = ?", [runId])
assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0)
assert.equal((await runById(runId)).status, 'cancelled')
})