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

@@ -274,6 +274,130 @@ const cancelPending = async (runId) => {
return Number(result?.affectedRows || 0)
}
// ── Phase 3: the controls a human works ────────────────────────────────────
//
// Four statements, and every one of them is guarded on the status it is allowed
// to act from rather than trusting the button that was pressed. The run console
// decides what to OFFER; these decide what may happen, and they disagree on
// purpose — a console rendered thirty seconds ago is a console describing a run
// that has since moved.
//
// **A parked step is `running` with a NULL lease**, and that pair is the whole
// vocabulary these need. `park()` above is the only thing that produces it, so
// `status = 'running' AND claim_expires_at IS NULL` names a cue waiting on a
// human and cannot name a step some process is mid-dispatch on. Confirm and skip
// are both written against it, which is what makes them safe to expose to a
// moderator: neither can touch a step the runner is holding.
/**
* The highest `seq` of a step in this phase that is not still `pending` — the
* furthest the phase has got — or null if none of it has been attempted.
*
* It exists for the retry control, and the definition is chosen to agree with
* the runner's own cursor rather than to look tidy. Steps within a phase are
* strictly serial, so the last step that is not pending is the last one the
* runner worked on; if the run is `paused` that step is what it paused at.
*
* **The near miss worth recording: "the lowest step that is not settled" is the
* wrong rule**, and it looks right. `nextOpenStep` selects `pending` and
* `running` only, so a `failed` step is one the runner has already stepped OVER
* — which is exactly what an `on_failure` of `skip` produces. Under that rule a
* phase whose second step failed-and-skipped and whose fifth then failed-and-
* paused would offer retry on the second, re-queueing a row behind the runner's
* cursor where it would sit pending for ever.
*/
const lastStartedSeq = async (runId, phase) => {
const [row] = await query(
`SELECT MAX(seq) AS seq FROM event_run_steps
WHERE run_id = ? AND phase = ? AND status <> 'pending'`,
[runId, phase],
)
return row?.seq === null || row?.seq === undefined ? null : Number(row.seq)
}
/**
* Resolve a parked step: the GM cue's confirm.
*
* `done` rather than `skipped` — a human saying they did the thing is the step
* having succeeded, and it is the only outcome under which the instruction was
* actually carried out. The note is kept in `last_error` for the same reason the
* park's is: it is the column the console already renders beside the step, and a
* second one for prose would be a column two writers disagree about.
*/
const confirmParked = async (id, note) => {
const result = await query(
`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`,
[note ? String(note).slice(0, 500) : null, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Skip a step a human has decided not to run: `pending`, or a parked cue.
*
* This is what `skipped` was reserved for (§L). A `running` step with a live
* lease is excluded — nothing can recall a command already sent — and a `failed`
* one is excluded because it is already terminal and the run's own resume is
* what carries the phase past it.
*/
const skipByHuman = async (id, reason) => {
const result = await query(
`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))`,
[reason ? String(reason).slice(0, 500) : null, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Put a failed step back in the queue for another attempt.
*
* **`attempts` goes back to zero, and that is not the rule Engagement Phase 14
* arrived at being broken.** That rule is about SWEEPS: an automatic path that
* reset a counter made the ceiling unreachable and the row immortal. This is a
* named person deciding, once, that the thing which failed three times will work
* now — `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does unattended, and a
* human is the thing it is unattended from. The decision is in the run log with
* the actor on it.
*/
const requeue = async (id) => {
const result = await query(
`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'`,
[id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Close out every step a cancelled run will never run: pending, and parked.
*
* Wider than `cancelPending` by exactly one case, and deliberately so. §L leaves
* a `running` step alone because nothing can recall a sent command — but a
* parked cue is not a sent command, it is an instruction nobody is holding, and
* leaving it `running` after the run was cancelled would leave the console
* claiming a cancelled event is still waiting for someone. The live lease is
* what distinguishes them, and it is in the WHERE clause.
*/
const cancelOpen = async (runId) => {
const result = await query(
`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))`,
[runId],
)
return Number(result?.affectedRows || 0)
}
module.exports = {
listForRun,
listForPhase,
@@ -289,4 +413,9 @@ module.exports = {
holdNext,
reclaimStale,
cancelPending,
lastStartedSeq,
confirmParked,
skipByHuman,
requeue,
cancelOpen,
}