feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s

A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

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 22:11:20 -05:00
parent 9c23c5fd0e
commit 9bc0bf5a3d
26 changed files with 2646 additions and 51 deletions

View File

@@ -19,6 +19,7 @@
const registries = require('../../../modules/registries')
const spec = require('../../../events/spec')
const conditionGrammar = require('../../../engagement/conditions')
const definitionsDb = require('../../../model/events/eventDefinitions.db')
const definitions = require('../../../model/events/eventDefinitions.model')
const versionsDb = require('../../../model/events/eventVersions.db')
@@ -146,11 +147,40 @@ exports.catalog = (_req, res) => {
onFailure: spec.ON_FAILURE,
onFailureByRisk: spec.ON_FAILURE_BY_RISK,
scheduleKinds: spec.SCHEDULE_KINDS,
// **The trigger catalog is served here too, and not borrowed from
// `/admin/engagement/triggers`** (Phase 5). §C's claim is that the trigger
// catalog a module already ships IS the catalog of things that can advance a
// phase — so it is the same registry, read twice. What differs is who may
// read it: the engagement route is `adminOnly`, and event definitions are
// authored by `admin` AND `editor`. Pointing this editor at that route would
// have left an editor writing a trigger id from memory into a field the save
// path then refused.
//
// Each declaration is reduced to what the gate form needs — id, label and
// the variables a `where` may name. Everything else on a trigger (its
// audience, its ceiling, its subject key) is about who gets MAILED, which is
// a different question and not this screen's.
triggers: registries.allTriggers().map((t) => ({
id: t.id,
label: t.label,
description: t.description,
owner: t.owner,
variables: (t.variables || []).map((v) => ({
name: v.name,
type: v.type,
required: v.required,
description: v.description,
})),
})),
operators: conditionGrammar.vocabulary(),
advanceKinds: spec.ADVANCE_KINDS,
limits: {
maxPhases: spec.MAX_PHASES,
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
maxSteps: spec.MAX_STEPS,
defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
maxAdvanceCount: spec.MAX_ADVANCE_COUNT,
maxAfterSeconds: spec.MAX_AFTER_SECONDS,
},
})
}
@@ -255,6 +285,10 @@ exports.getRun = async (req, res) => {
run: shapeRun(found.run),
steps: found.steps.map(shapeStep),
counts: found.counts,
// Already rendered in the condition builder's own words (Phase 5). See
// `eventRuns.model.detail` for why the sentence is built here and not in
// the browser.
gates: found.gates,
})
}
@@ -437,6 +471,20 @@ exports.resumeRun = async (req, res) => {
return res.json({ run: shapeRun(result.run) })
}
/** POST /api/v1/admin/events/runs/:runId/advance */
exports.advanceRunPhase = async (req, res) => {
const runId = asId(req.params.runId)
if (!runId) return res.status(400).json({ error: 'bad run id' })
const result = await controls.advancePhase(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.advanced',
detail: { runId, phase: result.phase, reason: req.body?.reason || null },
})
return res.json({ run: shapeRun(result.run), phase: result.phase })
}
/** POST /api/v1/admin/events/runs/:runId/cancel */
exports.cancelRun = async (req, res) => {
const runId = asId(req.params.runId)