feat(events): the minimal admin surface (Phase 3)
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:
@@ -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 definition’s 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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user