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:
@@ -8,10 +8,14 @@
|
||||
// a definition arriving from a future import or a restore gets the same answer
|
||||
// this screen does.
|
||||
//
|
||||
// **What is deliberately absent**: pause, resume, advance, cancel, step
|
||||
// skip/retry/confirm, cleanup and the action switchboard. Each of them acts on a
|
||||
// run in flight, and nothing is in flight until Phase 2 builds the runner. A
|
||||
// control that returns 200 and does nothing is worse than one that is not there.
|
||||
// **Phase 3 added the live run controls** at the bottom of this file: pause,
|
||||
// resume, cancel, and a step's confirm, skip and retry. What is still absent is
|
||||
// `advance`, `cleanup` and the action switchboard — `advance` has no honest
|
||||
// meaning until Phase 5 gives a phase an advance condition, `cleanup` has no
|
||||
// ledger to work over until Phase 8, and the switchboard is Phase 6's. Each of
|
||||
// them is absent rather than stubbed, for the reason the whole set was in Phase
|
||||
// 1: a control that returns 200 and does nothing is worse than one that is not
|
||||
// there.
|
||||
|
||||
const registries = require('../../../modules/registries')
|
||||
const spec = require('../../../events/spec')
|
||||
@@ -21,6 +25,7 @@ const versionsDb = require('../../../model/events/eventVersions.db')
|
||||
const seriesDb = require('../../../model/events/eventSeries.db')
|
||||
const runsDb = require('../../../model/events/eventRuns.db')
|
||||
const runs = require('../../../model/events/eventRuns.model')
|
||||
const controls = require('../../../model/events/eventRunControls.model')
|
||||
const logDb = require('../../../model/events/eventRunLog.db')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
@@ -80,6 +85,10 @@ const shapeRun = (r) => ({
|
||||
endedAt: r.ended_at,
|
||||
lastError: r.last_error,
|
||||
createdAt: r.created_at,
|
||||
// How many steps are parked on a human. Derived, not a column, and surfaced on
|
||||
// the LIST as well as the console because a cue nobody notices is a run that
|
||||
// never advances while looking perfectly healthy from the outside.
|
||||
waitingSteps: Number(r.waiting_steps || 0),
|
||||
})
|
||||
|
||||
const shapeStep = (s) => ({
|
||||
@@ -91,6 +100,12 @@ const shapeStep = (s) => ({
|
||||
params: s.params,
|
||||
actionVersion: s.action_version,
|
||||
status: s.status,
|
||||
// `running` with no lease is a parked step (§E) — waiting on a human, with
|
||||
// nothing holding it. The console has to tell that apart from a step some
|
||||
// process is mid-dispatch on, and it must not do so by being shown the lease:
|
||||
// one derived boolean rather than `claimed_by` and `claim_expires_at`, which
|
||||
// are the runner's business and would invite a UI that reasoned about leases.
|
||||
parked: s.status === 'running' && !s.claim_expires_at,
|
||||
dueAt: s.due_at,
|
||||
attempts: s.attempts,
|
||||
onFailure: s.on_failure,
|
||||
@@ -313,3 +328,89 @@ exports.startRun = async (req, res) => {
|
||||
created: result.created,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Phase 3: the live run controls ─────────────────────────────────────────
|
||||
//
|
||||
// Six handlers, and each is the same four lines: read the ids out of the URL,
|
||||
// hand off to `eventRunControls`, log the manual transition to `activity_log`,
|
||||
// answer with the row. Every guard is in the model, where a control invoked from
|
||||
// anywhere else gets the same answer — which is the same division this file has
|
||||
// had since Phase 1.
|
||||
//
|
||||
// **The audit is written in two places on purpose, and they are not redundant.**
|
||||
// `event_run_log` is the run's own diagnostic record: queryable by phase and by
|
||||
// step, and it is what the console renders. `activity_log` is the deployment's
|
||||
// record of what staff did, and it is where "who cancelled the invasion" is
|
||||
// looked up months later by somebody who is not looking at that run. §J names
|
||||
// both.
|
||||
|
||||
/** POST /api/v1/admin/events/runs/:runId/pause */
|
||||
exports.pauseRun = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
if (!runId) return res.status(400).json({ error: 'bad run id' })
|
||||
const result = await controls.pause(runId, { reason: req.body?.reason }, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.run.paused', detail: { runId, reason: req.body?.reason || null } })
|
||||
return res.json({ run: shapeRun(result.run) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/runs/:runId/resume */
|
||||
exports.resumeRun = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
if (!runId) return res.status(400).json({ error: 'bad run id' })
|
||||
const result = await controls.resume(runId, {}, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.run.resumed', detail: { runId } })
|
||||
return res.json({ run: shapeRun(result.run) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/runs/:runId/cancel */
|
||||
exports.cancelRun = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
if (!runId) return res.status(400).json({ error: 'bad run id' })
|
||||
const result = await controls.cancel(runId, { reason: req.body?.reason }, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.run.cancelled',
|
||||
detail: { runId, reason: req.body?.reason || null, cancelledSteps: result.cancelledSteps },
|
||||
})
|
||||
return res.json({ run: shapeRun(result.run), cancelledSteps: result.cancelledSteps })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/confirm */
|
||||
exports.confirmStep = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
const stepId = asId(req.params.stepId)
|
||||
if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
|
||||
const result = await controls.confirmStep(runId, stepId, { note: req.body?.note }, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.step.confirmed', detail: { runId, stepId, action: result.step.action_id } })
|
||||
return res.json({ step: shapeStep(result.step) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/skip */
|
||||
exports.skipStep = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
const stepId = asId(req.params.stepId)
|
||||
if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
|
||||
const result = await controls.skipStep(runId, stepId, { reason: req.body?.reason }, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.step.skipped',
|
||||
detail: { runId, stepId, action: result.step.action_id, reason: req.body?.reason || null },
|
||||
})
|
||||
return res.json({ step: shapeStep(result.step) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/retry */
|
||||
exports.retryStep = async (req, res) => {
|
||||
const runId = asId(req.params.runId)
|
||||
const stepId = asId(req.params.stepId)
|
||||
if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
|
||||
const result = await controls.retryStep(runId, stepId, {}, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.step.retried', detail: { runId, stepId, action: result.step.action_id } })
|
||||
return res.json({ step: shapeStep(result.step), run: shapeRun(result.run), resumed: result.resumed })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user