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

@@ -131,6 +131,8 @@ function installStubs() {
return true
}
runsDb.statusOf = async (id) => store.runs.get(id)?.status || null
runsDb.setHealth = async (id, health) => {
const r = store.runs.get(id)
if (!r || r.health === health) return false
@@ -687,3 +689,51 @@ test('the dispatch envelope carries what §F says it carries', async () => {
assert.equal(envelope.idempotencyKey.length, 40)
assert.deepEqual(Object.keys(envelope).sort(), ['actor', 'idempotencyKey', 'params', 'runId', 'scope', 'stepId', 'verify'])
})
test('a run paused mid-batch stops there rather than draining the rest of the phase', async () => {
// The whole value of a pause is that it takes effect NOW. `advanceRun` drains
// up to STEPS_PER_TICK steps from one run inside a single tick, so a status
// re-read only at the top of the tick would answer a pause by dispatching
// another two dozen steps. Written by pausing from inside an action's own
// `perform`, which is the only moment that race is reproducible.
register([
scriptedAction('test.pauser', {
perform: async ({ runId }) => {
await runsDb.transition(runId, ['starting', 'running'], 'paused', { clearClaim: true })
return { ok: true }
},
}),
scriptedAction('test.after'),
])
const id = seedRun([
{
key: 'main',
label: 'Main',
steps: [step('test.pauser'), step('test.after'), step('test.after')],
},
])
await runner.tick(T0)
assert.equal(run(id).status, 'paused')
const [first, second, third] = stepsOf(id)
assert.equal(first.status, 'done', 'the step that was already dispatched finishes')
assert.equal(second.status, 'pending', 'nothing after it ran')
assert.equal(third.status, 'pending')
assert.equal(scripted['test.after'], undefined, 'the later action was never called')
})
test('a resumed run picks up from the step it stopped at', async () => {
register([scriptedAction('test.a'), scriptedAction('test.b')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.a'), step('test.b')] }])
await runner.tick(T0)
assert.equal(run(id).status, 'completed')
// And the mirror of it: a run parked at `paused` is not picked up at all, which
// is what `findDue`'s omission of the status buys.
const other = seedRun([{ key: 'main', label: 'Main', steps: [step('test.a')] }], { status: 'paused' })
await runner.tick(T0)
assert.equal(stepsOf(other)[0].status, 'pending', 'a paused run is not swept')
})