feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user