feat(events): conditions, phase advancement and the diagnosis panel (Phase 5) #187
@@ -522,6 +522,8 @@ export const api = {
|
|||||||
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
|
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
|
||||||
cancelEventRun: (runId, reason) =>
|
cancelEventRun: (runId, reason) =>
|
||||||
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }),
|
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }),
|
||||||
|
advanceEventRun: (runId, reason) =>
|
||||||
|
req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
|
||||||
confirmEventStep: (runId, stepId, note) =>
|
confirmEventStep: (runId, stepId, note) =>
|
||||||
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
|
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
|
||||||
skipEventStep: (runId, stepId, reason) =>
|
skipEventStep: (runId, stepId, reason) =>
|
||||||
|
|||||||
@@ -47,14 +47,27 @@ export function lastStartedSeqOf(steps, phase) {
|
|||||||
* happen is cancelled, not paused. `cancel` is everything non-terminal — "this
|
* happen is cancelled, not paused. `cancel` is everything non-terminal — "this
|
||||||
* is not happening" is a decision made before a run starts as often as during
|
* is not happening" is a decision made before a run starts as often as during
|
||||||
* one.
|
* one.
|
||||||
|
*
|
||||||
|
* **`advance` is offered only when the phase is genuinely waiting on its gate**,
|
||||||
|
* which is the same test the server makes and is stated here in the same words
|
||||||
|
* on purpose: this decides what is *offered*, the server decides what is
|
||||||
|
* *allowed*, and a button that is present and always refused is the "control
|
||||||
|
* that answers 409 and does nothing" this feature has refused twice. The gate
|
||||||
|
* must be open-and-unsatisfied AND no step of the phase may still be pending or
|
||||||
|
* running — a phase held by a step is held by the step, and skip is its control.
|
||||||
*/
|
*/
|
||||||
export function runControlsFor(run) {
|
export function runControlsFor(run, gates = [], steps = []) {
|
||||||
if (!run) return { pause: false, resume: false, cancel: false }
|
if (!run) return { pause: false, resume: false, cancel: false, advance: false }
|
||||||
const terminal = isTerminalRun(run.status)
|
const terminal = isTerminalRun(run.status)
|
||||||
|
const gate = (gates || []).find((g) => g.phase === run.currentPhase)
|
||||||
|
const stepOpen = (steps || []).some(
|
||||||
|
(s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
pause: ['starting', 'running'].includes(run.status),
|
pause: ['starting', 'running'].includes(run.status),
|
||||||
resume: run.status === 'paused',
|
resume: run.status === 'paused',
|
||||||
cancel: !terminal,
|
cancel: !terminal,
|
||||||
|
advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +138,41 @@ export function blankStep(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function blankPhase(phases) {
|
export function blankPhase(phases) {
|
||||||
return { key: nextPhaseKey(phases), label: 'New phase', steps: [] }
|
return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The advance gate as the FORM holds it (Phase 5) — three fields that are
|
||||||
|
* always present and mostly empty, rather than a discriminated union the form
|
||||||
|
* has to rebuild every time the dropdown moves.
|
||||||
|
*
|
||||||
|
* `kind: ''` is "no condition", which is what nearly every phase is and what
|
||||||
|
* every phase was before this. The form keeps a half-typed `on` gate's trigger
|
||||||
|
* while the author looks at `after`, because a dropdown that discards what was
|
||||||
|
* typed under the other option is one an operator learns to be afraid of.
|
||||||
|
*/
|
||||||
|
export function blankAdvance() {
|
||||||
|
return { kind: '', after: '30m', on: '', count: 1, whereText: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCE_KINDS = [
|
||||||
|
{ value: '', label: 'When its steps are done' },
|
||||||
|
{ value: 'after', label: 'After a fixed delay' },
|
||||||
|
{ value: 'on', label: 'When something happens in the game' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The stored gate, as the form's three fields. */
|
||||||
|
export function advanceFormFrom(advance) {
|
||||||
|
const blank = blankAdvance()
|
||||||
|
if (!advance) return blank
|
||||||
|
if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after }
|
||||||
|
return {
|
||||||
|
...blank,
|
||||||
|
kind: 'on',
|
||||||
|
on: advance.on || '',
|
||||||
|
count: advance.count ?? 1,
|
||||||
|
whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The editor's working state, from what `GET /admin/events/:id` returned. */
|
/** The editor's working state, from what `GET /admin/events/:id` returned. */
|
||||||
@@ -145,6 +192,7 @@ export function formFromDefinition(event) {
|
|||||||
phases: (spec.phases || []).map((p) => ({
|
phases: (spec.phases || []).map((p) => ({
|
||||||
key: p.key || '',
|
key: p.key || '',
|
||||||
label: p.label || '',
|
label: p.label || '',
|
||||||
|
advance: advanceFormFrom(p.advance),
|
||||||
steps: (p.steps || []).map((s) => ({
|
steps: (p.steps || []).map((s) => ({
|
||||||
actionId: s.actionId || '',
|
actionId: s.actionId || '',
|
||||||
label: s.label || '',
|
label: s.label || '',
|
||||||
@@ -157,6 +205,31 @@ export function formFromDefinition(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One phase's advance gate, as the spec shape — or null when it has none.
|
||||||
|
*
|
||||||
|
* Only the `where` JSON is checked, and only because text that is not JSON
|
||||||
|
* cannot be put in a request at all. **Whether the predicate is VALID is the
|
||||||
|
* server's answer**, and the whole trap of Phase 5 is that it is answered at
|
||||||
|
* save with the offending variable named — re-deciding it here would be a second
|
||||||
|
* validator drifting from the one that matters, exactly as with a step's params.
|
||||||
|
*/
|
||||||
|
export function advancePayload(advance, where, errors) {
|
||||||
|
if (!advance || !advance.kind) return null
|
||||||
|
if (advance.kind === 'after') return { after: advance.after }
|
||||||
|
|
||||||
|
const out = { on: advance.on, count: Number(advance.count) || 1 }
|
||||||
|
const text = String(advance.whereText || '').trim()
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
out.where = JSON.parse(text)
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${where}, advance condition: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The form, as a request body — or the list of everything wrong with it.
|
* The form, as a request body — or the list of everything wrong with it.
|
||||||
*
|
*
|
||||||
@@ -173,9 +246,16 @@ export function formFromDefinition(event) {
|
|||||||
*/
|
*/
|
||||||
export function payloadFromForm(form) {
|
export function payloadFromForm(form) {
|
||||||
const errors = []
|
const errors = []
|
||||||
const phases = (form.phases || []).map((phase, pi) => ({
|
const phases = (form.phases || []).map((phase, pi) => {
|
||||||
|
const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors)
|
||||||
|
return {
|
||||||
key: phase.key,
|
key: phase.key,
|
||||||
label: phase.label,
|
label: phase.label,
|
||||||
|
// Omitted rather than sent as null when there is no gate, which is what
|
||||||
|
// `events/spec.js` stores for the same reason: a spec full of
|
||||||
|
// `"advance": null` makes the first phase to gain one look like an edit to
|
||||||
|
// every phase in the version diff.
|
||||||
|
...(where ? { advance: where } : {}),
|
||||||
steps: (phase.steps || []).map((step, si) => {
|
steps: (phase.steps || []).map((step, si) => {
|
||||||
const out = { actionId: step.actionId }
|
const out = { actionId: step.actionId }
|
||||||
if (step.label) out.label = step.label
|
if (step.label) out.label = step.label
|
||||||
@@ -188,7 +268,8 @@ export function payloadFromForm(form) {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}),
|
}),
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (errors.length) return { ok: false, errors }
|
if (errors.length) return { ok: false, errors }
|
||||||
|
|
||||||
@@ -376,6 +457,9 @@ const KIND_WORDS = {
|
|||||||
'step.status': 'Step',
|
'step.status': 'Step',
|
||||||
'step.retry': 'Step retried',
|
'step.retry': 'Step retried',
|
||||||
'step.parked': 'Waiting on a human',
|
'step.parked': 'Waiting on a human',
|
||||||
|
'phase.gate': 'Advance condition set',
|
||||||
|
'condition.evaluated': 'Condition evaluated',
|
||||||
|
'phase.advanced': 'Phase advanced',
|
||||||
note: 'Note',
|
note: 'Note',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +499,21 @@ export function describeLogLine(line) {
|
|||||||
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
|
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
|
||||||
case 'run.created':
|
case 'run.created':
|
||||||
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
|
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
|
||||||
|
case 'phase.gate':
|
||||||
|
return d.kind === 'after'
|
||||||
|
? `${line.phase} advances ${d.after} after it started`
|
||||||
|
: `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}`
|
||||||
|
// Both outcomes are logged, and the near miss is the useful one: it is the
|
||||||
|
// difference between "the boss did spawn, in the wrong region" and "no boss
|
||||||
|
// has spawned", which look identical on every other line of this log.
|
||||||
|
case 'condition.evaluated':
|
||||||
|
return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${
|
||||||
|
d.satisfied ? ', condition met' : ''
|
||||||
|
}`
|
||||||
|
case 'phase.advanced':
|
||||||
|
return d.because === 'forced'
|
||||||
|
? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
|
||||||
|
: `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
|
||||||
default:
|
default:
|
||||||
return logKindWord(line?.kind)
|
return logKindWord(line?.kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
formFromDefinition,
|
formFromDefinition,
|
||||||
payloadFromForm,
|
payloadFromForm,
|
||||||
blankPhase,
|
blankPhase,
|
||||||
|
blankAdvance,
|
||||||
|
ADVANCE_KINDS,
|
||||||
blankStep,
|
blankStep,
|
||||||
describeSchedule,
|
describeSchedule,
|
||||||
scheduleFromForm,
|
scheduleFromForm,
|
||||||
@@ -105,6 +107,12 @@ export default function EventEditor() {
|
|||||||
|
|
||||||
const actions = useMemo(() => catalog?.actions || [], [catalog])
|
const actions = useMemo(() => catalog?.actions || [], [catalog])
|
||||||
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
|
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
|
||||||
|
// Phase 5. Served with the actions on the same route, so an EDITOR sees the
|
||||||
|
// same catalog an admin does — `/admin/engagement/triggers` is admin-only, and
|
||||||
|
// an editor writing a trigger id from memory into a field the save path then
|
||||||
|
// refuses is the failure this avoids.
|
||||||
|
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
|
||||||
|
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
|
||||||
|
|
||||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||||
|
|
||||||
@@ -467,10 +475,77 @@ export default function EventEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
||||||
The key is what the run console groups by and what “phase 3 has not started” names, so it
|
The key is what the run console groups by and what “phase 3 has not started” names, so it
|
||||||
cannot change once runs exist. A phase advances when every one of its steps is finished;
|
cannot change once runs exist.
|
||||||
advancing on a condition instead is a later phase.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* ── The advance condition (Phase 5) ──
|
||||||
|
A gate is an ADDITIONAL condition and never a replacement, which is
|
||||||
|
what the caption has to say: a phase whose steps are still running
|
||||||
|
is not advanced by a boss that spawned early. */}
|
||||||
|
<div style={{ marginTop: 12, borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ flex: '1 1 240px' }}>
|
||||||
|
<span className="field-label">This phase advances</span>
|
||||||
|
<select className="input" value={phase.advance?.kind || ''}
|
||||||
|
onChange={(e) => setPhase(pi, { advance: { ...(phase.advance || blankAdvance()), kind: e.target.value } })}>
|
||||||
|
{ADVANCE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{phase.advance?.kind === 'after' && (
|
||||||
|
<label style={{ flex: '0 1 160px' }}>
|
||||||
|
<span className="field-label">Delay</span>
|
||||||
|
<input className="input" value={phase.advance.after}
|
||||||
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, after: e.target.value } })}
|
||||||
|
placeholder="30m" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{phase.advance?.kind === 'on' && (
|
||||||
|
<>
|
||||||
|
<label style={{ flex: '1 1 240px' }}>
|
||||||
|
<span className="field-label">Trigger</span>
|
||||||
|
<select className="input" value={phase.advance.on}
|
||||||
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, on: e.target.value } })}>
|
||||||
|
<option value="">Choose a trigger…</option>
|
||||||
|
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ flex: '0 1 110px' }}>
|
||||||
|
<span className="field-label">How many</span>
|
||||||
|
<input className="input" type="number" min="1" value={phase.advance.count}
|
||||||
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, count: e.target.value } })} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase.advance?.kind === 'on' && (
|
||||||
|
<label style={{ display: 'block', marginTop: 10 }}>
|
||||||
|
<span className="field-label">Only when (JSON, optional)</span>
|
||||||
|
<textarea className="input" rows={3} spellCheck={false} value={phase.advance.whereText}
|
||||||
|
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, whereText: e.target.value } })}
|
||||||
|
placeholder={'{ "variable": "region", "cmp": "eq", "value": "Yew" }'} />
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||||
|
A raw JSON field, and a placeholder for the same reason the params box is one — the
|
||||||
|
condition builder proper is a later phase. It is checked at save against what the
|
||||||
|
trigger declares, and a variable the trigger does not have comes back named.
|
||||||
|
{triggerById.get(phase.advance.on)?.variables?.length > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<code>{phase.advance.on}</code> declares{' '}
|
||||||
|
{triggerById.get(phase.advance.on).variables.map((v) => `${v.name} (${v.type})`).join(', ')}.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
||||||
|
{phase.advance?.kind
|
||||||
|
? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
|
||||||
|
: 'The phase advances the moment every one of its steps is finished.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
{phase.steps.map((step, si) => {
|
{phase.steps.map((step, si) => {
|
||||||
const action = actionById.get(step.actionId)
|
const action = actionById.get(step.actionId)
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ import {
|
|||||||
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
|
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
|
||||||
// stay that way for ever unless somebody presses confirm. It is called out above
|
// stay that way for ever unless somebody presses confirm. It is called out above
|
||||||
// the step list rather than being one row in it.
|
// the step list rather than being one row in it.
|
||||||
|
//
|
||||||
|
// **Phase 5 gave it a second one of those, and the panel is this phase's real
|
||||||
|
// deliverable** (§ Observability): a phase whose steps have all finished and
|
||||||
|
// whose advance condition has not been met is also `running` and also
|
||||||
|
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
|
||||||
|
// steps, in the condition builder's own words — and the sentence is the
|
||||||
|
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
|
||||||
|
// defined in the condition grammar and a second renderer in the browser would
|
||||||
|
// be a second opinion about what `gte` reads as.
|
||||||
|
|
||||||
const POLL_MS = 5000
|
const POLL_MS = 5000
|
||||||
|
|
||||||
@@ -52,11 +61,89 @@ const STEP_COLOR = {
|
|||||||
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
|
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
|
||||||
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
|
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds as an operator reads them — the same vocabulary the spec authors a
|
||||||
|
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
|
||||||
|
* obviously the same kind of thing.
|
||||||
|
*/
|
||||||
|
function elapsed(seconds) {
|
||||||
|
const s = Math.max(0, Number(seconds) || 0)
|
||||||
|
if (s < 60) return `${s} sec`
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)} min`
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
return m ? `${h} hr ${m} min` : `${h} hr`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One phase gate, as the panel draws it.
|
||||||
|
*
|
||||||
|
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
|
||||||
|
* was released by the third boss" is the same question as the live one, asked
|
||||||
|
* after the fact, and it is the one an operator asks the morning after.
|
||||||
|
*/
|
||||||
|
function GateRow({ gate, current }) {
|
||||||
|
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
|
||||||
|
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
|
||||||
|
Phase <strong>{gate.phase}</strong>
|
||||||
|
{current && !gate.satisfied ? ' has not started' : ''}
|
||||||
|
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
|
||||||
|
{gate.stalled && ' — STALLED'}
|
||||||
|
</div>
|
||||||
|
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
|
||||||
|
{gate.kind === 'after' ? (
|
||||||
|
<>
|
||||||
|
<dt className="dim">waiting for</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
|
||||||
|
<dt className="dim">until</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<dt className="dim">waiting on</dt>
|
||||||
|
<dd style={{ margin: 0 }}>
|
||||||
|
<code>{gate.waitingOn}</code>
|
||||||
|
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> — any firing</span>}
|
||||||
|
</dd>
|
||||||
|
<dt className="dim">seen so far</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="dim">since</dt>
|
||||||
|
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
|
||||||
|
{gate.kind === 'on' && gate.lastEvent && (
|
||||||
|
<>
|
||||||
|
<dt className="dim">last related event</dt>
|
||||||
|
<dd style={{ margin: 0 }}>
|
||||||
|
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
|
||||||
|
{' — '}
|
||||||
|
{/* The near miss is the valuable half: "the boss did spawn, in
|
||||||
|
Britain" and "no boss has spawned" are different answers and
|
||||||
|
look identical without this line. */}
|
||||||
|
{gate.lastEvent.matched ? 'counted' : 'did not count'}
|
||||||
|
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
|
||||||
|
<span className="dim">
|
||||||
|
{' ('}
|
||||||
|
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
|
||||||
|
{')'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function EventRun() {
|
export default function EventRun() {
|
||||||
const { runId } = useParams()
|
const { runId } = useParams()
|
||||||
const [run, setRun] = useState(null)
|
const [run, setRun] = useState(null)
|
||||||
const [steps, setSteps] = useState([])
|
const [steps, setSteps] = useState([])
|
||||||
const [counts, setCounts] = useState({})
|
const [counts, setCounts] = useState({})
|
||||||
|
const [gates, setGates] = useState([])
|
||||||
const [lines, setLines] = useState([])
|
const [lines, setLines] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -75,6 +162,7 @@ export default function EventRun() {
|
|||||||
setRun(detail.run)
|
setRun(detail.run)
|
||||||
setSteps(detail.steps || [])
|
setSteps(detail.steps || [])
|
||||||
setCounts(detail.counts || {})
|
setCounts(detail.counts || {})
|
||||||
|
setGates(detail.gates || [])
|
||||||
setLines(log.log || [])
|
setLines(log.log || [])
|
||||||
}, [runId])
|
}, [runId])
|
||||||
|
|
||||||
@@ -130,7 +218,8 @@ export default function EventRun() {
|
|||||||
if (error) return <ErrorState message={error} />
|
if (error) return <ErrorState message={error} />
|
||||||
if (!run) return <ErrorState message="No such run." />
|
if (!run) return <ErrorState message="No such run." />
|
||||||
|
|
||||||
const controls = runControlsFor(run)
|
const controls = runControlsFor(run, gates, steps)
|
||||||
|
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
|
||||||
const parked = steps.filter(isParked)
|
const parked = steps.filter(isParked)
|
||||||
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
|
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
|
||||||
|
|
||||||
@@ -206,6 +295,10 @@ export default function EventRun() {
|
|||||||
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
|
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.advance}
|
||||||
|
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
|
||||||
|
Advance phase
|
||||||
|
</button>
|
||||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
|
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
|
||||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
|
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
|
||||||
Cancel run
|
Cancel run
|
||||||
@@ -219,6 +312,36 @@ export default function EventRun() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Why this phase has not started (Phase 5) ──
|
||||||
|
Above the step list for the same reason the parked cue is: a phase
|
||||||
|
waiting on a condition is `running` and looks completely healthy, and
|
||||||
|
the one screen an operator opens to find out why nothing is happening
|
||||||
|
must say so before they have to read a log. */}
|
||||||
|
{gates.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="panel-flat"
|
||||||
|
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
|
||||||
|
>
|
||||||
|
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||||
|
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
|
||||||
|
</h3>
|
||||||
|
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
|
||||||
|
{waiting ? (
|
||||||
|
<>
|
||||||
|
Every step of this phase has finished. It advances when the condition below is met —
|
||||||
|
nothing times out, and <em>Advance phase</em> is how a person overrides it.
|
||||||
|
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'What each phase of this run waited for, and what released it.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{gates.map((gate) => (
|
||||||
|
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Waiting on a person ── */}
|
{/* ── Waiting on a person ── */}
|
||||||
{parked.length > 0 && (
|
{parked.length > 0 && (
|
||||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ import {
|
|||||||
scheduleFormFrom,
|
scheduleFormFrom,
|
||||||
scheduleFromForm,
|
scheduleFromForm,
|
||||||
isProjected,
|
isProjected,
|
||||||
|
blankAdvance,
|
||||||
|
advanceFormFrom,
|
||||||
|
advancePayload,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
MONTHLY_NTHS,
|
MONTHLY_NTHS,
|
||||||
|
ADVANCE_KINDS,
|
||||||
} from '../src/lib/eventAuthoring.js'
|
} from '../src/lib/eventAuthoring.js'
|
||||||
|
|
||||||
// lib/eventAuthoring.js — what the three Events screens say and what they let
|
// lib/eventAuthoring.js — what the three Events screens say and what they let
|
||||||
@@ -416,3 +420,140 @@ test('a projection is told apart from a run, because only one of them can be act
|
|||||||
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
|
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
|
||||||
assert.equal(isProjected(null), false)
|
assert.equal(isProjected(null), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// -- The advance gate (Phase 5) ---------------------------------------------
|
||||||
|
//
|
||||||
|
// What this screen must get right is what it OFFERS. `advance` is the one
|
||||||
|
// control in this feature whose whole point is that it overrides the engine, so
|
||||||
|
// a button offered in a state the server refuses would be the "control that
|
||||||
|
// answers 409 and does nothing" this feature has refused twice.
|
||||||
|
|
||||||
|
test('advance is offered only when the phase is waiting on its gate', () => {
|
||||||
|
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
|
||||||
|
const done = [{ phase: 'boss', status: 'done' }]
|
||||||
|
|
||||||
|
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
|
||||||
|
|
||||||
|
// A phase with an open step is held by the STEP, and skip is its control.
|
||||||
|
assert.equal(
|
||||||
|
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// A phase with no gate advances on its steps and always has.
|
||||||
|
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
|
||||||
|
// A gate already satisfied is not waiting.
|
||||||
|
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
|
||||||
|
// And a run that is not running is waiting on nothing.
|
||||||
|
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||||
|
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runControlsFor still answers with no gates or steps at all', () => {
|
||||||
|
// The three Phase 3 controls were called with one argument for two phases, and
|
||||||
|
// the calendar still calls it that way.
|
||||||
|
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
|
||||||
|
assert.equal(controls.pause, true)
|
||||||
|
assert.equal(controls.advance, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate round-trips through the form without losing the other shape', () => {
|
||||||
|
assert.deepEqual(advanceFormFrom(null), blankAdvance())
|
||||||
|
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
|
||||||
|
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
|
||||||
|
|
||||||
|
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
|
||||||
|
assert.equal(on.kind, 'on')
|
||||||
|
assert.equal(on.count, 3)
|
||||||
|
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
|
||||||
|
|
||||||
|
// The dropdown's three options, and the empty one is what nearly every phase
|
||||||
|
// is — so it is first and it is not called "none".
|
||||||
|
assert.equal(ADVANCE_KINDS[0].value, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advancePayload sends one shape, and only reports a JSON error', () => {
|
||||||
|
const errors = []
|
||||||
|
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
|
||||||
|
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
|
||||||
|
assert.deepEqual(
|
||||||
|
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', whereText: '' }, 'Phase 1', errors),
|
||||||
|
{ on: 'uo.champ.boss_up', count: 2 },
|
||||||
|
'an empty predicate is omitted, not sent as an empty object',
|
||||||
|
)
|
||||||
|
assert.equal(errors.length, 0)
|
||||||
|
|
||||||
|
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{ not json' }, 'Phase 2 "Boss"', errors)
|
||||||
|
assert.equal(errors.length, 1)
|
||||||
|
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
|
||||||
|
|
||||||
|
// Whether the predicate is VALID is the server's answer, named variable and
|
||||||
|
// all. This only refuses text that cannot be put in a request.
|
||||||
|
const clean = []
|
||||||
|
assert.deepEqual(
|
||||||
|
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
|
||||||
|
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
|
||||||
|
)
|
||||||
|
assert.equal(clean.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a phase with no gate sends no `advance` key', () => {
|
||||||
|
const form = formFromDefinition({
|
||||||
|
title: 'x',
|
||||||
|
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
|
||||||
|
})
|
||||||
|
const built = payloadFromForm(form)
|
||||||
|
assert.equal(built.ok, true)
|
||||||
|
assert.equal('advance' in built.payload.spec.phases[0], false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an authored gate survives the round trip through the form', () => {
|
||||||
|
const form = formFromDefinition({
|
||||||
|
title: 'x',
|
||||||
|
spec: {
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [
|
||||||
|
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
|
||||||
|
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const built = payloadFromForm(form)
|
||||||
|
assert.equal(built.ok, true)
|
||||||
|
assert.deepEqual(built.payload.spec.phases[0].advance, {
|
||||||
|
on: 'uo.champ.boss_up',
|
||||||
|
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||||
|
count: 2,
|
||||||
|
})
|
||||||
|
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
|
||||||
|
assert.match(
|
||||||
|
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
|
||||||
|
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
|
||||||
|
)
|
||||||
|
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
|
||||||
|
assert.match(
|
||||||
|
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
|
||||||
|
/did not count — 0 of 2/,
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
|
||||||
|
/counted — 2 of 2, condition met/,
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
|
||||||
|
/boss advanced by hand after 4080s: never spawned/,
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
|
||||||
|
/loot advanced on its deadline after 600s/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -2349,3 +2349,64 @@ CREATE TABLE IF NOT EXISTS event_run_log (
|
|||||||
-- scan of every line this deployment has ever logged.
|
-- scan of every line this deployment has ever logged.
|
||||||
INDEX idx_evlog_at (at)
|
INDEX idx_evlog_at (at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- What a phase is waiting for, and how far it has got (§E, Phase 5).
|
||||||
|
--
|
||||||
|
-- A phase used to advance on one fact — every step terminal — and that fact
|
||||||
|
-- lives in `event_run_steps`. An advance CONDITION is a second fact, and it is
|
||||||
|
-- not derivable from any row that already exists: `{ on: 'uo.champ.boss_up',
|
||||||
|
-- count: 3 }` is a tally of things that happened between one tick and the next,
|
||||||
|
-- and the runner is not running when they happen. This table is where a firing
|
||||||
|
-- is counted at the moment it fires.
|
||||||
|
--
|
||||||
|
-- **One row per (run, phase), created at phase entry by INSERT IGNORE**, the
|
||||||
|
-- same idempotence `materialisePhase` has and for the same reason: a process
|
||||||
|
-- that died between entering a phase and writing this must not open a second
|
||||||
|
-- gate on the next tick.
|
||||||
|
--
|
||||||
|
-- **The tally is incremented by one statement with the threshold in it**, never
|
||||||
|
-- read-then-written — the argument `event_run_budget`'s conditional increment
|
||||||
|
-- makes, one phase early. Two emits arriving together each add one, and exactly
|
||||||
|
-- one of them crosses `needed`.
|
||||||
|
--
|
||||||
|
-- `last_event` holds ONLY the variables the condition names, not the payload.
|
||||||
|
-- It exists to answer "what did the last one look like, and why did it not
|
||||||
|
-- count", and a copy of a whole game event's data is a second copy of exactly
|
||||||
|
-- the content `engagement_sends` is careful not to keep.
|
||||||
|
--
|
||||||
|
-- `satisfied_by` is a VARCHAR rather than an ENUM for `event_run_log.kind`'s
|
||||||
|
-- reason: the set can grow (an authored timeout was considered and declined for
|
||||||
|
-- Phase 5) and this project has no migration system for a column alter. `kind`
|
||||||
|
-- IS an ENUM, because §E closes it at two shapes.
|
||||||
|
CREATE TABLE IF NOT EXISTS event_run_phase_gates (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
run_id BIGINT NOT NULL,
|
||||||
|
phase VARCHAR(64) NOT NULL,
|
||||||
|
kind ENUM('after','on') NOT NULL,
|
||||||
|
-- kind='after': normalised to seconds at save, so the runner never parses a
|
||||||
|
-- duration string. `due_at` is entered_at + this, computed once at entry.
|
||||||
|
after_seconds INT NULL,
|
||||||
|
-- kind='on': the trigger being waited on and the predicate over its declared
|
||||||
|
-- variables. `conditions` is NULL for "any firing of this trigger".
|
||||||
|
trigger_id VARCHAR(96) NULL,
|
||||||
|
conditions JSON NULL,
|
||||||
|
needed INT NOT NULL DEFAULT 1,
|
||||||
|
tally INT NOT NULL DEFAULT 0,
|
||||||
|
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
due_at DATETIME NULL,
|
||||||
|
last_event JSON NULL,
|
||||||
|
last_event_at DATETIME NULL,
|
||||||
|
satisfied_at DATETIME NULL,
|
||||||
|
satisfied_by VARCHAR(16) NULL, -- 'condition' | 'elapsed' | 'forced'
|
||||||
|
forced_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_evgate_user FOREIGN KEY (forced_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
-- Entry is INSERT IGNORE against this.
|
||||||
|
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||||
|
-- The emit path's only query: every open gate waiting on this trigger. It runs
|
||||||
|
-- on every game event of every trigger anything waits on, so it is the one
|
||||||
|
-- index in this feature that is on a hot path rather than an admin screen.
|
||||||
|
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -527,6 +527,15 @@
|
|||||||
"requireAuth"
|
"requireAuth"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/events/runs/:runId/advance",
|
||||||
|
"handlers": 2,
|
||||||
|
"gates": [
|
||||||
|
"noindex",
|
||||||
|
"requireAuth"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/events/runs/:runId/cancel",
|
"path": "/api/v1/admin/events/runs/:runId/cancel",
|
||||||
|
|||||||
@@ -233,6 +233,10 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/events/runs/:runId"
|
"path": "/api/v1/admin/events/runs/:runId"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/events/runs/:runId/advance"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/events/runs/:runId/cancel"
|
"path": "/api/v1/admin/events/runs/:runId/cancel"
|
||||||
|
|||||||
244
server/src/events/gates.js
Normal file
244
server/src/events/gates.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// ── Phase advance gates: the emit-path observer, and the words for the panel ─
|
||||||
|
//
|
||||||
|
// EVENTS.md §E and § Observability, and Phase 5 of EVENTS_PLAN.md. Two things
|
||||||
|
// live here because they are two halves of one claim — that an operator can
|
||||||
|
// answer *"why didn't phase 3 start?"* without reading a server log:
|
||||||
|
//
|
||||||
|
// `observe(event)` — the trigger stream's other subscriber. Beside
|
||||||
|
// `engine.dispatch`, on the same seam, with the same
|
||||||
|
// fire-and-forget posture.
|
||||||
|
// `describe(gate)` — the same gate rendered in the condition builder's own
|
||||||
|
// words, which is what the diagnosis panel shows.
|
||||||
|
//
|
||||||
|
// **Why the counting happens here and not on the runner's tick.** A gate that
|
||||||
|
// waits for three boss spawns is counting things that happen *between* ticks. A
|
||||||
|
// poller cannot count them: fifteen seconds after the third spawn there is
|
||||||
|
// nothing left to observe, and a tally kept in a process's memory is a tally a
|
||||||
|
// restart silently sets back to zero — with the phase then waiting for three
|
||||||
|
// more of something that already happened. So the emit path writes, and the tick
|
||||||
|
// reads. That division is the whole design of this file.
|
||||||
|
//
|
||||||
|
// **The cost of that, and its bound.** Every game event of every trigger some
|
||||||
|
// run is waiting on costs one indexed lookup, and the common answer is zero
|
||||||
|
// rows. Only when a gate is open does anything else happen, and then it is one
|
||||||
|
// UPDATE per open gate — bounded by how many runs can be waiting on one trigger
|
||||||
|
// at once, which is bounded by how many runs exist.
|
||||||
|
//
|
||||||
|
// **The words are rendered here, on the server, not in the client.** The panel's
|
||||||
|
// entire value is that it reads the way the condition builder reads — `gte` as
|
||||||
|
// *"is at least"*, `present` as *"is present"* — and those labels are defined in
|
||||||
|
// `engagement/conditions.js`. A renderer in the browser would be a second
|
||||||
|
// implementation of a grammar the server owns, and the first operator to meet a
|
||||||
|
// clause it spelled differently would be the operator diagnosing a stalled run
|
||||||
|
// at two in the morning.
|
||||||
|
|
||||||
|
const gatesDb = require('../model/events/eventPhaseGates.db')
|
||||||
|
const runsDb = require('../model/events/eventRuns.db')
|
||||||
|
const logDb = require('../model/events/eventRunLog.db')
|
||||||
|
const conditions = require('../engagement/conditions')
|
||||||
|
const log = require('../utils/logger')('event-gates')
|
||||||
|
|
||||||
|
// How long an `on` gate may wait before the run is called `stalled` (§E's third
|
||||||
|
// health value, which nothing had ever written before this phase). It is a
|
||||||
|
// VISIBILITY threshold and not a timeout: nothing advances, nothing fails, and
|
||||||
|
// the operator decides. An hour is long enough that a champion spawn nobody has
|
||||||
|
// killed yet is not an alarm, and short enough that a run which will wait for
|
||||||
|
// ever is on the screen inside one shift.
|
||||||
|
//
|
||||||
|
// It does not apply to an `after` gate. A phase waiting out six hours it was
|
||||||
|
// authored to wait is not stalled, it is working, and health that said otherwise
|
||||||
|
// would train an operator to ignore it.
|
||||||
|
const STALL_MS = Number(process.env.EVENT_PHASE_STALL_MS) || 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Every variable a condition tree names, in the order it names them. */
|
||||||
|
function variablesIn(node, out = []) {
|
||||||
|
if (!node || typeof node !== 'object') return out
|
||||||
|
if (Array.isArray(node.nodes)) {
|
||||||
|
node.nodes.forEach((child) => variablesIn(child, out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (node.variable && !out.includes(node.variable)) out.push(node.variable)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = (v) => (typeof v === 'string' ? `"${v}"` : String(v))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One condition tree as a sentence, using the grammar's own operator labels.
|
||||||
|
*
|
||||||
|
* `null` answers null rather than "always": the caller renders *"on any
|
||||||
|
* `uo.champ.boss_up`"* for a gate with no predicate, and a phrase saying
|
||||||
|
* "everything is true" would be a clause an operator has to read past.
|
||||||
|
*/
|
||||||
|
function phrase(node) {
|
||||||
|
if (!node || typeof node !== 'object') return null
|
||||||
|
if (node.op === 'not') {
|
||||||
|
const inner = phrase((node.nodes || [])[0])
|
||||||
|
return inner ? `not (${inner})` : null
|
||||||
|
}
|
||||||
|
if (node.op === 'and' || node.op === 'or') {
|
||||||
|
const parts = (node.nodes || []).map(phrase).filter(Boolean)
|
||||||
|
if (!parts.length) return null
|
||||||
|
// Parenthesised only where it changes the reading. A flat `and` of three
|
||||||
|
// comparisons is a sentence; the same three wrapped in brackets is a
|
||||||
|
// diagnosis an operator has to parse rather than read.
|
||||||
|
const joined = parts.map((p) => (p.includes(' or ') || p.includes(' and ') ? `(${p})` : p))
|
||||||
|
return joined.join(node.op === 'and' ? ' and ' : ' or ')
|
||||||
|
}
|
||||||
|
const operator = conditions.OPERATORS[node.cmp]
|
||||||
|
if (!operator) return null
|
||||||
|
if (operator.arity === 0) return `${node.variable} ${operator.label}`
|
||||||
|
if (operator.arity === 'list') return `${node.variable} ${operator.label} ${node.value.map(quote).join(', ')}`
|
||||||
|
return `${node.variable} ${operator.label} ${quote(node.value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape the run console renders — a gate as an operator reads it.
|
||||||
|
*
|
||||||
|
* Derived rather than stored, every field of it. `stalled` in particular is a
|
||||||
|
* comparison against the clock and not a column: a threshold that had been
|
||||||
|
* written into a row at entry could not be changed by an operator raising
|
||||||
|
* `EVENT_PHASE_STALL_MS`, and one written at the moment of stalling would be a
|
||||||
|
* fourth writer on a row two already share.
|
||||||
|
*/
|
||||||
|
function describe(gate, now = new Date()) {
|
||||||
|
if (!gate) return null
|
||||||
|
const since = new Date(gate.entered_at)
|
||||||
|
const satisfied = Boolean(gate.satisfied_at)
|
||||||
|
// **A satisfied gate's clock stops when it was satisfied**, not at read time.
|
||||||
|
// Live it answers "how long has this phase been waiting"; afterwards it
|
||||||
|
// answers "how long did it wait", and those are the same number only while it
|
||||||
|
// is still waiting. The walk caught it disagreeing with `phase.advanced`'s
|
||||||
|
// own `waitedSeconds` by the age of the screen — 139s beside a logged 121.
|
||||||
|
const until = satisfied ? new Date(gate.satisfied_at) : now
|
||||||
|
const elapsedSeconds = Math.max(0, Math.round((until.getTime() - since.getTime()) / 1000))
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: gate.phase,
|
||||||
|
kind: gate.kind,
|
||||||
|
satisfied,
|
||||||
|
satisfiedAt: gate.satisfied_at || null,
|
||||||
|
satisfiedBy: gate.satisfied_by || null,
|
||||||
|
since: gate.entered_at,
|
||||||
|
elapsedSeconds,
|
||||||
|
// An `after` gate is never stalled; an `on` gate is stalled once it has
|
||||||
|
// waited past the threshold and not before.
|
||||||
|
stalled: !satisfied && gate.kind === 'on' && now.getTime() - since.getTime() >= STALL_MS,
|
||||||
|
...(gate.kind === 'after'
|
||||||
|
? { after: gate.after_seconds, dueAt: gate.due_at }
|
||||||
|
: {
|
||||||
|
waitingOn: gate.trigger_id,
|
||||||
|
where: phrase(gate.conditions),
|
||||||
|
seen: gate.tally,
|
||||||
|
needed: gate.needed,
|
||||||
|
lastEvent: gate.last_event,
|
||||||
|
lastEventAt: gate.last_event_at || null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trigger stream's second subscriber.
|
||||||
|
*
|
||||||
|
* Called from `engagementEmit.emit` beside `engine.dispatch` and, like it, never
|
||||||
|
* awaited and never allowed to reject. An emit is a module saying something
|
||||||
|
* happened in the game; whether some event run cared is core's business, and a
|
||||||
|
* failure of core's must not become the module's control flow.
|
||||||
|
*
|
||||||
|
* **Every firing is logged, matched or not** (§ Observability: "trigger
|
||||||
|
* evaluations that did and did not satisfy a condition"). The near miss is the
|
||||||
|
* more valuable of the two on the night: *"the boss did spawn, in Britain"* and
|
||||||
|
* *"no boss has spawned"* are different answers, and without this line they look
|
||||||
|
* identical on the screen.
|
||||||
|
*/
|
||||||
|
async function observe(event) {
|
||||||
|
const summary = { gates: 0, counted: 0, satisfied: 0 }
|
||||||
|
try {
|
||||||
|
const open = await gatesDb.openForTrigger(event.triggerId)
|
||||||
|
summary.gates = open.length
|
||||||
|
if (!open.length) return summary
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
for (const gate of open) {
|
||||||
|
const matched = conditions.evaluate(gate.conditions, event.data || {})
|
||||||
|
|
||||||
|
// ONLY the variables the condition names, never the payload. This row is
|
||||||
|
// read back onto an admin screen, and a copy of a whole game event's data
|
||||||
|
// is a second copy of exactly the content `engagement_sends` is careful
|
||||||
|
// not to keep. The named variables are also the useful ones: they are the
|
||||||
|
// reason it did or did not count.
|
||||||
|
const named = variablesIn(gate.conditions)
|
||||||
|
const lastEvent = {
|
||||||
|
trigger: event.triggerId,
|
||||||
|
at: event.occurredAt,
|
||||||
|
subject: event.subject ?? null,
|
||||||
|
matched,
|
||||||
|
variables: Object.fromEntries(
|
||||||
|
named.filter((n) => event.data && n in event.data).map((n) => [n, event.data[n]]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = matched
|
||||||
|
? await gatesDb.count(gate.id, { lastEvent, now })
|
||||||
|
: { counted: false, satisfied: false, tally: gate.tally, near: await gatesDb.noteNearMiss(gate.id, { lastEvent, now }) }
|
||||||
|
|
||||||
|
if (matched && result.counted) summary.counted += 1
|
||||||
|
if (result.satisfied) summary.satisfied += 1
|
||||||
|
|
||||||
|
await logDb.write({
|
||||||
|
runId: gate.run_id,
|
||||||
|
kind: 'condition.evaluated',
|
||||||
|
phase: gate.phase,
|
||||||
|
detail: {
|
||||||
|
trigger: event.triggerId,
|
||||||
|
matched,
|
||||||
|
// The tally the DATABASE holds after the write, not the one this
|
||||||
|
// process predicted — two emits arriving together each read the same
|
||||||
|
// stale number, and only one of them is right about what it became.
|
||||||
|
seen: result.tally ?? gate.tally,
|
||||||
|
needed: gate.needed,
|
||||||
|
satisfied: result.satisfied,
|
||||||
|
variables: lastEvent.variables,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.counted || summary.satisfied) {
|
||||||
|
log.info('phase gate advanced', { trigger: event.triggerId, ...summary })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('gate observation failed', { trigger: event.triggerId, message: err.message })
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this phase's gate open — and if it is not, why not?
|
||||||
|
*
|
||||||
|
* The runner's question, and the one place an `after` gate is closed: its
|
||||||
|
* deadline passing is not an event anything emits, so the tick that finds it
|
||||||
|
* past `due_at` is what records it. Doing that here rather than in the runner
|
||||||
|
* keeps `satisfied_by` a fact one file writes.
|
||||||
|
*
|
||||||
|
* Answers `{ open, gate }`. `open: true` with a null gate is a phase with no
|
||||||
|
* advance condition at all — every phase before this one, and most after it.
|
||||||
|
*/
|
||||||
|
async function check(runId, phase, now = new Date()) {
|
||||||
|
const gate = await gatesDb.forPhase(runId, phase)
|
||||||
|
if (!gate) return { open: true, gate: null }
|
||||||
|
if (gate.satisfied_at) return { open: true, gate }
|
||||||
|
|
||||||
|
if (gate.kind === 'after' && gate.due_at && new Date(gate.due_at) <= now) {
|
||||||
|
if (await gatesDb.satisfy(gate.id, 'elapsed', { now })) {
|
||||||
|
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||||
|
}
|
||||||
|
// Somebody else closed it between the read and the write — a force, or the
|
||||||
|
// tick that overran into this one. Either way it is open, and whichever
|
||||||
|
// reason won is the one in the row.
|
||||||
|
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { open: false, gate }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { observe, check, describe, phrase, variablesIn, STALL_MS }
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
// than preserved: a spec that silently carries `announcements` today is a spec
|
// than preserved: a spec that silently carries `announcements` today is a spec
|
||||||
// whose author believes announcements work, and the later phase that gives the
|
// whose author believes announcements work, and the later phase that gives the
|
||||||
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||||
// changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's
|
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
|
||||||
// `advance`, Phase 10 adds `announcements`.
|
// `advance`, Phase 10 adds `announcements`.
|
||||||
//
|
//
|
||||||
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
|
|
||||||
const registries = require('../modules/registries')
|
const registries = require('../modules/registries')
|
||||||
const recurrence = require('./recurrence')
|
const recurrence = require('./recurrence')
|
||||||
const { checkLiteral } = require('../engagement/conditions')
|
const conditionGrammar = require('../engagement/conditions')
|
||||||
|
const { checkLiteral } = conditionGrammar
|
||||||
|
|
||||||
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
|
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
|
||||||
// run console groups by, and it is what an operator reads in "phase 3 has not
|
// run console groups by, and it is what an operator reads in "phase 3 has not
|
||||||
@@ -45,6 +46,31 @@ const MAX_PHASES = 40
|
|||||||
const MAX_STEPS_PER_PHASE = 100
|
const MAX_STEPS_PER_PHASE = 100
|
||||||
const MAX_STEPS = 500
|
const MAX_STEPS = 500
|
||||||
|
|
||||||
|
// The two shapes a phase's `advance` may take (§E). There is deliberately no
|
||||||
|
// third: a gate that never opens is held, made visible and left to an operator
|
||||||
|
// (org lead, 2026-09-02), so there is no authored timeout and no disposition to
|
||||||
|
// validate. Adding one later is one key and one branch, and this is the list a
|
||||||
|
// reader should find it missing from.
|
||||||
|
const ADVANCE_KINDS = ['after', 'on']
|
||||||
|
|
||||||
|
// `after: '30m'` — one integer and one unit, and nothing else. No `1h30m`, no
|
||||||
|
// fractions: the whole reason a duration is a string here rather than the plain
|
||||||
|
// integer seconds `core.wait` takes is that an operator proofreads it, and a
|
||||||
|
// grammar that admits two spellings of ninety minutes is one an operator has to
|
||||||
|
// parse rather than read.
|
||||||
|
const AFTER_RE = /^(\d{1,6})(s|m|h|d)$/
|
||||||
|
const AFTER_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 }
|
||||||
|
const MIN_AFTER_SECONDS = 1
|
||||||
|
// A paste guard rather than a policy, in the spirit of MAX_PHASES: thirty days
|
||||||
|
// is longer than any event this system is for, and a phase gate of ten years is
|
||||||
|
// a typo that would otherwise hold a run — and its concurrency key — for ever.
|
||||||
|
const MAX_AFTER_SECONDS = 30 * 86_400
|
||||||
|
|
||||||
|
// How many firings one `on` gate may wait for. Bounded for the reason MAX_LIST
|
||||||
|
// is: it is authored into a JSON column, and "count: 100000" is a phase that
|
||||||
|
// never advances written as one that eventually does.
|
||||||
|
const MAX_ADVANCE_COUNT = 1000
|
||||||
|
|
||||||
// The four closed shapes of §E. `manual` is first because it is the default and
|
// The four closed shapes of §E. `manual` is first because it is the default and
|
||||||
// what an unscheduled draft carries; the other three are recurrences the runner
|
// what an unscheduled draft carries; the other three are recurrences the runner
|
||||||
// expands into occurrences ahead of time.
|
// expands into occurrences ahead of time.
|
||||||
@@ -158,6 +184,140 @@ function validateSchedule(kind, raw, errors) {
|
|||||||
return { kind: 'manual' }
|
return { kind: 'manual' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `'30m'` into seconds, or answer null.
|
||||||
|
*
|
||||||
|
* Exported because the run console renders the same duration back and must not
|
||||||
|
* grow a second opinion about what `'2h'` means.
|
||||||
|
*/
|
||||||
|
function parseAfter(raw) {
|
||||||
|
const m = AFTER_RE.exec(String(raw ?? ''))
|
||||||
|
if (!m) return null
|
||||||
|
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
|
||||||
|
if (seconds < MIN_AFTER_SECONDS || seconds > MAX_AFTER_SECONDS) return null
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds back to the largest whole unit that expresses them exactly. */
|
||||||
|
function formatAfter(seconds) {
|
||||||
|
for (const unit of ['d', 'h', 'm']) {
|
||||||
|
const size = AFTER_UNIT_SECONDS[unit]
|
||||||
|
if (seconds % size === 0) return `${seconds / size}${unit}`
|
||||||
|
}
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a phase's `advance` gate and answer the normalised form of it.
|
||||||
|
*
|
||||||
|
* Returns `null` for a phase with no gate — the common case, and the behaviour
|
||||||
|
* every phase had before Phase 5: it advances when its steps go terminal and on
|
||||||
|
* nothing else. A gate is an ADDITIONAL condition, never a replacement, so a
|
||||||
|
* phase whose steps are still running is not advanced by a satisfied gate.
|
||||||
|
*
|
||||||
|
* **The duration is normalised the way `days` is** — `'120m'` is stored as
|
||||||
|
* `'2h'` — because the spec is diffed between versions, and two spellings of one
|
||||||
|
* delay differing as JSON is a version history that reports edits nobody made.
|
||||||
|
*
|
||||||
|
* **`where` is validated against the trigger's DECLARATION, at save, with the
|
||||||
|
* offending variable named.** This is the whole trap of this phase, and it is
|
||||||
|
* `engagement/conditions.js`'s own argument one system across: a predicate that
|
||||||
|
* silently reads `undefined` is a phase that silently never advances, and the
|
||||||
|
* night you find out is the night of the event.
|
||||||
|
*
|
||||||
|
* **A trigger nobody registers makes the gate DORMANT, not invalid.** Same rule
|
||||||
|
* as a step naming an action no installed module declares: it saves, so
|
||||||
|
* uninstalling a module is not destructive to an author's work, and it refuses
|
||||||
|
* to publish, because a version runs are pinned to must not wait on a trigger
|
||||||
|
* that can never fire.
|
||||||
|
*/
|
||||||
|
function validateAdvance(raw, path, errors) {
|
||||||
|
if (raw === undefined || raw === null) return null
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
errors.push(`${path}: expected an object`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(raw)
|
||||||
|
const named = ADVANCE_KINDS.filter((k) => keys.includes(k))
|
||||||
|
if (named.length !== 1) {
|
||||||
|
errors.push(`${path}: expected exactly one of "after" or "on"`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const kind = named[0]
|
||||||
|
|
||||||
|
if (kind === 'after') {
|
||||||
|
const extra = keys.filter((k) => k !== 'after')
|
||||||
|
if (extra.length) {
|
||||||
|
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "after" gate`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const seconds = parseAfter(raw.after)
|
||||||
|
if (seconds === null) {
|
||||||
|
errors.push(
|
||||||
|
`${path}.after: expected a duration like "30m" — a whole number of s, m, h or d, ` +
|
||||||
|
`between ${MIN_AFTER_SECONDS}s and ${formatAfter(MAX_AFTER_SECONDS)}`,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Only the canonical string is stored. The seconds are re-derived by the one
|
||||||
|
// caller that needs them (the runner, when it opens the gate) through the
|
||||||
|
// exported `parseAfter`, rather than kept beside it as a second field two
|
||||||
|
// versions of the spec could disagree about.
|
||||||
|
return { after: formatAfter(seconds) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `dormant` is in this list for the reason `actionVersion` and `dormant` are
|
||||||
|
// in a step's — **validate must accept its own output.** A saved spec is
|
||||||
|
// re-validated on every later save and again at publish, so a field the
|
||||||
|
// validator itself added and then refused would make the second save of any
|
||||||
|
// gated definition impossible. It is accepted and then RECOMPUTED below,
|
||||||
|
// never trusted: dormancy is whether anybody registers that trigger right
|
||||||
|
// now, not what was true when the spec was last written.
|
||||||
|
const extra = keys.filter((k) => !['on', 'where', 'count', 'dormant'].includes(k))
|
||||||
|
if (extra.length) {
|
||||||
|
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "on" gate`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerId = raw.on
|
||||||
|
if (typeof triggerId !== 'string' || !triggerId) {
|
||||||
|
errors.push(`${path}.on: expected a trigger id`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 1
|
||||||
|
if (raw.count !== undefined && raw.count !== null) {
|
||||||
|
if (!Number.isInteger(raw.count) || raw.count < 1 || raw.count > MAX_ADVANCE_COUNT) {
|
||||||
|
errors.push(`${path}.count: expected a whole number between 1 and ${MAX_ADVANCE_COUNT}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
count = raw.count
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaration = registries.eventTrigger(triggerId)
|
||||||
|
if (!declaration) {
|
||||||
|
// Dormant, exactly as an unregistered action is. `where` is carried through
|
||||||
|
// unvalidated and unnormalised — there is no declaration to check it
|
||||||
|
// against, and dropping it would silently delete an author's predicate the
|
||||||
|
// moment a module was uninstalled.
|
||||||
|
return { on: triggerId, where: raw.where ?? null, count, dormant: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const checked = conditionGrammar.validate(declaration, raw.where ?? null)
|
||||||
|
if (!checked.ok) {
|
||||||
|
// The grammar paths its own errors from the root token `conditions`; this
|
||||||
|
// re-roots them at the phase so an author reading five of them at once can
|
||||||
|
// tell which phase each belongs to. The text after the path — the part that
|
||||||
|
// names the variable — is the grammar's, unchanged.
|
||||||
|
checked.errors.forEach((e) =>
|
||||||
|
errors.push(`${path}.where${e.startsWith('conditions') ? e.slice('conditions'.length) : `: ${e}`}`),
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { on: triggerId, where: checked.conditions, count, dormant: false }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check one authored param object against an action's declared params.
|
* Check one authored param object against an action's declared params.
|
||||||
*
|
*
|
||||||
@@ -269,11 +429,13 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
|||||||
errors.push(`${path}: expected an object`)
|
errors.push(`${path}: expected an object`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps'].includes(k))
|
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps', 'advance'].includes(k))
|
||||||
if (extra.length) {
|
if (extra.length) {
|
||||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')} (a phase gains "advance" in Phase 5)`)
|
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const advance = validateAdvance(rawPhase.advance, `${path}.advance`, errors)
|
||||||
|
|
||||||
const key = rawPhase.key
|
const key = rawPhase.key
|
||||||
if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
|
if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
|
||||||
errors.push(`${path}.key: bad phase key "${key}"`)
|
errors.push(`${path}.key: bad phase key "${key}"`)
|
||||||
@@ -366,7 +528,11 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
phases.push({ key, label: rawPhase.label, steps })
|
// `advance` is omitted rather than written as null when there is no gate:
|
||||||
|
// the overwhelming majority of phases have none, and a spec full of
|
||||||
|
// `"advance": null` is a diff between two versions that says something
|
||||||
|
// changed about every phase the first time one phase gained a gate.
|
||||||
|
phases.push(advance ? { key, label: rawPhase.label, steps, advance } : { key, label: rawPhase.label, steps })
|
||||||
})
|
})
|
||||||
|
|
||||||
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
|
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
|
||||||
@@ -386,10 +552,18 @@ const actionIdsIn = (spec) =>
|
|||||||
* SAVING (that is what makes an uninstall non-destructive), and it must stop
|
* SAVING (that is what makes an uninstall non-destructive), and it must stop
|
||||||
* them PUBLISHING, because publishing is what makes a version a thing runs are
|
* them PUBLISHING, because publishing is what makes a version a thing runs are
|
||||||
* pinned to and a run cannot dispatch a verb nobody registers.
|
* pinned to and a run cannot dispatch a verb nobody registers.
|
||||||
|
*
|
||||||
|
* **A phase's advance gate is dormant on the same rule** (Phase 5), and it is in
|
||||||
|
* the same list because it fails for the same reason and the message already
|
||||||
|
* reads correctly for both: a version that waits on a trigger nothing can emit
|
||||||
|
* is a run that would never leave that phase.
|
||||||
*/
|
*/
|
||||||
function publishable(spec) {
|
function publishable(spec) {
|
||||||
const dormant = (spec?.phases || [])
|
const phases = spec?.phases || []
|
||||||
.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId))
|
const dormant = [
|
||||||
|
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
|
||||||
|
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
|
||||||
|
]
|
||||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,8 +579,13 @@ module.exports = {
|
|||||||
actionIdsIn,
|
actionIdsIn,
|
||||||
emptySpec,
|
emptySpec,
|
||||||
defaultOnFailure,
|
defaultOnFailure,
|
||||||
|
parseAfter,
|
||||||
|
formatAfter,
|
||||||
PHASE_KEY,
|
PHASE_KEY,
|
||||||
SCHEDULE_KINDS,
|
SCHEDULE_KINDS,
|
||||||
|
ADVANCE_KINDS,
|
||||||
|
MAX_ADVANCE_COUNT,
|
||||||
|
MAX_AFTER_SECONDS,
|
||||||
ON_FAILURE,
|
ON_FAILURE,
|
||||||
ON_FAILURE_BY_RISK,
|
ON_FAILURE_BY_RISK,
|
||||||
MAX_PHASES,
|
MAX_PHASES,
|
||||||
|
|||||||
187
server/src/model/events/eventPhaseGates.db.js
Normal file
187
server/src/model/events/eventPhaseGates.db.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// ── event_run_phase_gates — SQL only ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// EVENTS.md §E, and Phase 5 of EVENTS_PLAN.md. A phase used to advance on one
|
||||||
|
// fact — every step terminal — and that fact lives in `event_run_steps`. An
|
||||||
|
// advance CONDITION is a second fact, and it is the only one in this feature
|
||||||
|
// that is not derivable from a row somebody already wrote: `{ on:
|
||||||
|
// 'uo.champ.boss_up', count: 3 }` counts things that happen between one tick and
|
||||||
|
// the next, and the runner is not running when they happen. A gate row is where
|
||||||
|
// a firing is counted at the moment it fires.
|
||||||
|
//
|
||||||
|
// **Two writers, and they are not the same process leg.** The RUNNER opens a
|
||||||
|
// gate (at phase entry) and closes an `after` one (when its deadline passes);
|
||||||
|
// the EMIT PATH increments and closes an `on` one. Everything here is therefore
|
||||||
|
// written as a single guarded statement rather than a read-then-write, which is
|
||||||
|
// the same argument `event_run_budget`'s conditional increment makes one phase
|
||||||
|
// early and the same one `runsDb.transition` makes for a status.
|
||||||
|
//
|
||||||
|
// **Nothing here throws at the emit path.** `observe` is called from inside a
|
||||||
|
// game-event handler by way of `ctx.events.emit`, exactly as `engine.dispatch`
|
||||||
|
// is, and a database problem of core's must not become a module's control flow.
|
||||||
|
// The catch lives in `events/gates.js`; this file is the statements.
|
||||||
|
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
const { parseJson } = require('./eventJson')
|
||||||
|
|
||||||
|
const hydrate = (row) =>
|
||||||
|
row && {
|
||||||
|
...row,
|
||||||
|
conditions: parseJson(row.conditions, null),
|
||||||
|
last_event: parseJson(row.last_event, null),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a phase's gate. **INSERT IGNORE against `uq_evgate_phase`**, so a process
|
||||||
|
* that died between entering a phase and getting here opens no second gate on
|
||||||
|
* the next tick — the idempotence `materialisePhase` has, for the same reason.
|
||||||
|
*
|
||||||
|
* Answers whether a row was created, which is what lets the caller log
|
||||||
|
* `phase.entered`'s gate detail exactly once.
|
||||||
|
*/
|
||||||
|
async function open({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = new Date() }) {
|
||||||
|
// `due_at` is computed here, once, from the moment the phase was entered —
|
||||||
|
// never re-derived on a later tick from a `now` that has moved. A deadline
|
||||||
|
// recomputed every fifteen seconds is a deadline that never arrives.
|
||||||
|
const dueAt = kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null
|
||||||
|
const result = await query(
|
||||||
|
`INSERT IGNORE INTO event_run_phase_gates
|
||||||
|
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
runId,
|
||||||
|
phase,
|
||||||
|
kind,
|
||||||
|
afterSeconds,
|
||||||
|
triggerId,
|
||||||
|
conditions === null ? null : JSON.stringify(conditions),
|
||||||
|
needed,
|
||||||
|
now,
|
||||||
|
dueAt,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return Number(result?.affectedRows || 0) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One run's gate for one phase, or null. */
|
||||||
|
const forPhase = async (runId, phase) =>
|
||||||
|
hydrate(
|
||||||
|
(
|
||||||
|
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? AND phase = ? LIMIT 1', [
|
||||||
|
runId,
|
||||||
|
phase,
|
||||||
|
])
|
||||||
|
)[0] || null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Every gate a run has ever opened, oldest first — what the run console reads. */
|
||||||
|
const listForRun = async (runId) =>
|
||||||
|
(
|
||||||
|
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? ORDER BY entered_at, id', [
|
||||||
|
runId,
|
||||||
|
])
|
||||||
|
).map(hydrate)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every OPEN gate waiting on one trigger, with the run's status and phase.
|
||||||
|
*
|
||||||
|
* This is the emit path's only query and the one index in this feature on a hot
|
||||||
|
* path. The join is what keeps a gate belonging to a cancelled run from counting
|
||||||
|
* for ever: a run that will never advance again must stop tallying, and its
|
||||||
|
* row's `satisfied_at` is not what says so.
|
||||||
|
*
|
||||||
|
* **`paused` counts.** The world does not stop because an operator paused the
|
||||||
|
* console, and discarding firings that arrived during a pause would make pause a
|
||||||
|
* destructive control — the tally an operator came back to would be lower than
|
||||||
|
* the one they left, with nothing recording the difference.
|
||||||
|
*/
|
||||||
|
const openForTrigger = async (triggerId) =>
|
||||||
|
(
|
||||||
|
await query(
|
||||||
|
`SELECT g.* FROM event_run_phase_gates g
|
||||||
|
JOIN event_runs r ON r.id = g.run_id
|
||||||
|
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
|
||||||
|
AND r.status IN ('running','paused')
|
||||||
|
AND r.current_phase = g.phase
|
||||||
|
LIMIT 200`,
|
||||||
|
[triggerId],
|
||||||
|
)
|
||||||
|
).map(hydrate)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count one matching firing, and close the gate if that was the last one needed.
|
||||||
|
*
|
||||||
|
* **One statement, with the threshold inside it.** Two emits arriving together
|
||||||
|
* each add one and exactly one of them crosses `needed`; a read-then-write would
|
||||||
|
* let both see 2 of 3 and neither satisfy, or both satisfy and advance a phase
|
||||||
|
* twice. `WHERE satisfied_at IS NULL` is what makes a late arrival a no-op
|
||||||
|
* rather than a tally that keeps climbing after the phase moved on.
|
||||||
|
*
|
||||||
|
* Answers `{ counted, satisfied }` read back from the row, so the caller logs
|
||||||
|
* the tally the database actually holds rather than the one it predicted.
|
||||||
|
*/
|
||||||
|
async function count(gateId, { lastEvent = null, now = new Date() } = {}) {
|
||||||
|
// **THE INCREMENT MUST BE LAST, and this is not style.** MariaDB evaluates an
|
||||||
|
// UPDATE's SET assignments LEFT TO RIGHT, each one seeing the values already
|
||||||
|
// assigned by the ones before it — which is a documented departure from
|
||||||
|
// standard SQL, and it is invisible in a stub. With `tally = tally + 1` first,
|
||||||
|
// the CASE that follows reads the ALREADY-INCREMENTED tally, so `tally + 1 >=
|
||||||
|
// needed` is really `new + 1 >= needed` and a gate needing two firings closes
|
||||||
|
// on the first. Written this way, both CASEs see the old tally and say exactly
|
||||||
|
// what they read as. `eventRunnerSql.test.js` is what catches a reorder, and
|
||||||
|
// it is what caught this one.
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE event_run_phase_gates
|
||||||
|
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
|
||||||
|
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
|
||||||
|
last_event = ?,
|
||||||
|
last_event_at = ?,
|
||||||
|
tally = tally + 1
|
||||||
|
WHERE id = ? AND satisfied_at IS NULL`,
|
||||||
|
[now, lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
|
||||||
|
)
|
||||||
|
if (Number(result?.affectedRows || 0) !== 1) return { counted: false, satisfied: false }
|
||||||
|
const row = await byId(gateId)
|
||||||
|
return { counted: true, satisfied: Boolean(row?.satisfied_at), tally: row?.tally ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that a firing was seen and did NOT match.
|
||||||
|
*
|
||||||
|
* Only `last_event_at` and `last_event` move: the tally is what the phase is
|
||||||
|
* waiting on, and a near miss is not progress. It is recorded at all because
|
||||||
|
* "the boss did spawn, in the wrong region" and "no boss has spawned" are
|
||||||
|
* different answers to the operator's question, and only this column can tell
|
||||||
|
* them apart on a screen.
|
||||||
|
*/
|
||||||
|
async function noteNearMiss(gateId, { lastEvent = null, now = new Date() } = {}) {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE event_run_phase_gates
|
||||||
|
SET last_event = ?, last_event_at = ?
|
||||||
|
WHERE id = ? AND satisfied_at IS NULL`,
|
||||||
|
[lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
|
||||||
|
)
|
||||||
|
return Number(result?.affectedRows || 0) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a gate for a reason that is not a matching firing: `'elapsed'` when an
|
||||||
|
* `after` deadline passed, `'forced'` when a human pressed advance.
|
||||||
|
*
|
||||||
|
* Guarded on `satisfied_at IS NULL` like everything else here, so a force that
|
||||||
|
* races the tick that would have opened the gate anyway loses harmlessly and the
|
||||||
|
* log records whichever actually happened rather than both.
|
||||||
|
*/
|
||||||
|
async function satisfy(gateId, by, { userId = null, now = new Date() } = {}) {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE event_run_phase_gates
|
||||||
|
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
|
||||||
|
WHERE id = ? AND satisfied_at IS NULL`,
|
||||||
|
[now, by, userId, gateId],
|
||||||
|
)
|
||||||
|
return Number(result?.affectedRows || 0) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = async (id) =>
|
||||||
|
hydrate((await query('SELECT * FROM event_run_phase_gates WHERE id = ? LIMIT 1', [id]))[0] || null)
|
||||||
|
|
||||||
|
module.exports = { open, forPhase, byId, listForRun, openForTrigger, count, noteNearMiss, satisfy }
|
||||||
@@ -7,15 +7,18 @@
|
|||||||
// needs no control, and a run that paused on a failed world write is the one
|
// needs no control, and a run that paused on a failed world write is the one
|
||||||
// that does.
|
// that does.
|
||||||
//
|
//
|
||||||
// **Two of §I's six run-level controls are deliberately not here.**
|
// **`advance` is the seventh, and it arrived in Phase 5 rather than Phase 3
|
||||||
// `advance` — force a phase forward — has no honest meaning yet: a phase today
|
// because that is when it started meaning something.** A phase used to advance
|
||||||
// advances when its steps go terminal, and the per-step skip already does that
|
// when its steps went terminal and on nothing else, so "force it anyway" named
|
||||||
// one step at a time. Phase 5 is what gives a phase an `advance` CONDITION, and
|
// no state an operator could be in; a phase with a gate can wait for a boss that
|
||||||
// that is the first moment "force it anyway" means something an operator could
|
// will never spawn, and then it names exactly one. It is the other half of the
|
||||||
// predict. `cleanup` needs Phase 8's resource ledger; there is nothing to
|
// diagnosis panel: a screen that explains why a phase has not started, beside a
|
||||||
// revert, so cancel takes `{ reason }` and gains `cleanup` when there is
|
// control that does something about it.
|
||||||
// something for it to do. Both are absent rather than inert, which is the
|
//
|
||||||
// posture Phase 1 set and Phase 2 kept.
|
// **One of §I's controls is still not here.** `cleanup` needs Phase 8's resource
|
||||||
|
// ledger; there is nothing to revert, so cancel takes `{ reason }` and gains
|
||||||
|
// `cleanup` when there is something for it to do. Absent rather than inert,
|
||||||
|
// which is the posture Phase 1 set and every phase since has kept.
|
||||||
//
|
//
|
||||||
// **Every control is guarded on the status it may act from, and the guard is a
|
// **Every control is guarded on the status it may act from, and the guard is a
|
||||||
// WHERE clause rather than a read-then-write.** A run console rendered thirty
|
// WHERE clause rather than a read-then-write.** A run console rendered thirty
|
||||||
@@ -33,6 +36,8 @@
|
|||||||
const runsDb = require('./eventRuns.db')
|
const runsDb = require('./eventRuns.db')
|
||||||
const stepsDb = require('./eventRunSteps.db')
|
const stepsDb = require('./eventRunSteps.db')
|
||||||
const logDb = require('./eventRunLog.db')
|
const logDb = require('./eventRunLog.db')
|
||||||
|
const gatesDb = require('./eventPhaseGates.db')
|
||||||
|
const gates = require('../../events/gates')
|
||||||
|
|
||||||
const MAX_REASON = 500
|
const MAX_REASON = 500
|
||||||
|
|
||||||
@@ -168,6 +173,67 @@ async function cancel(runId, { reason } = {}, userId = null) {
|
|||||||
return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
|
return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a phase forward: open its gate without the condition that would have.
|
||||||
|
*
|
||||||
|
* **It is legal only when the phase is actually waiting on a gate**, and the
|
||||||
|
* three refusals are the whole design. A run that is not `running` is not
|
||||||
|
* waiting on anything (409 naming what it is). A phase with no gate advances on
|
||||||
|
* its steps and always has, so forcing it would be a control that duplicated the
|
||||||
|
* runner rather than overriding it. And a phase whose steps have not all gone
|
||||||
|
* terminal is not being held by its gate — it is being held by a step, and the
|
||||||
|
* step-level skip is the honest control for that, one step at a time. A force
|
||||||
|
* that swept past pending steps would be a cancel of half a phase under a button
|
||||||
|
* labelled advance.
|
||||||
|
*
|
||||||
|
* **It satisfies the gate and stops.** The next tick advances the run, exactly
|
||||||
|
* as it does after `resume` — the phase transition, the next phase's
|
||||||
|
* materialisation, its own gate and the log lines are one sequence in
|
||||||
|
* `advanceRun`, and a second copy of it here would be a second opinion about
|
||||||
|
* what a phase boundary is. The response says which phase was released, so the
|
||||||
|
* console can say so before the tick lands.
|
||||||
|
*/
|
||||||
|
async function advancePhase(runId, { reason } = {}, userId = null) {
|
||||||
|
const run = await loadRun(runId)
|
||||||
|
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||||
|
if (run.status !== 'running') return conflict(`a ${run.status} run has no phase to advance`)
|
||||||
|
if (!run.current_phase) return conflict('this run has not entered a phase yet')
|
||||||
|
|
||||||
|
const gate = await gatesDb.forPhase(run.id, run.current_phase)
|
||||||
|
if (!gate) return conflict(`phase "${run.current_phase}" has no advance condition; skip its steps instead`)
|
||||||
|
if (gate.satisfied_at) return conflict(`phase "${run.current_phase}" is already past its advance condition`)
|
||||||
|
|
||||||
|
const openStep = await stepsDb.nextOpenStep(run.id, run.current_phase)
|
||||||
|
if (openStep) {
|
||||||
|
return conflict(
|
||||||
|
`phase "${run.current_phase}" is waiting on step ${openStep.seq} (${openStep.action_id}), not on its advance condition`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = clean(reason)
|
||||||
|
if (!(await gatesDb.satisfy(gate.id, 'forced', { userId }))) {
|
||||||
|
return conflict('this phase stopped waiting on its advance condition')
|
||||||
|
}
|
||||||
|
|
||||||
|
const described = gates.describe(gate)
|
||||||
|
await logDb.write({
|
||||||
|
runId: run.id,
|
||||||
|
kind: 'phase.advanced',
|
||||||
|
phase: run.current_phase,
|
||||||
|
detail: {
|
||||||
|
because: 'forced',
|
||||||
|
control: 'advance',
|
||||||
|
by: userId,
|
||||||
|
reason: note,
|
||||||
|
waitedSeconds: described.elapsedSeconds,
|
||||||
|
...(gate.kind === 'on'
|
||||||
|
? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed }
|
||||||
|
: { after: gate.after_seconds }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ok: true, run: await runsDb.getById(run.id), phase: run.current_phase }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step-level ────────────────────────────────────────────────────────────
|
// ── Step-level ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -293,4 +359,4 @@ async function retryStep(runId, stepId, options = {}, userId = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { pause, resume, cancel, confirmStep, skipStep, retryStep }
|
module.exports = { pause, resume, cancel, advancePhase, confirmStep, skipStep, retryStep }
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ const KINDS = [
|
|||||||
'step.retry', // a step failed transiently and will be attempted again
|
'step.retry', // a step failed transiently and will be attempted again
|
||||||
'step.parked', // a step is waiting on a human and nothing is holding it
|
'step.parked', // a step is waiting on a human and nothing is holding it
|
||||||
'phase.completed', // every step of a phase reached a terminal status
|
'phase.completed', // every step of a phase reached a terminal status
|
||||||
|
// Phase 5's four. `condition.evaluated` is written for BOTH outcomes (§
|
||||||
|
// Observability), and the non-matching one is the more valuable of the two on
|
||||||
|
// the night: "the boss did spawn, in Britain" and "no boss has spawned" are
|
||||||
|
// different answers to the same question and look identical without it.
|
||||||
|
'phase.gate', // a phase opened an advance gate, with what it waits for
|
||||||
|
'condition.evaluated', // a firing was tested against a gate, matched or not
|
||||||
|
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
|
||||||
]
|
]
|
||||||
|
|
||||||
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), r
|
|||||||
// only if every path a run can take reaches one of them.
|
// only if every path a run can take reaches one of them.
|
||||||
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
|
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
|
||||||
|
|
||||||
|
// §E's health values, worst last. Health is a HIGH-WATER MARK in this system —
|
||||||
|
// nothing has ever cleared `degraded`, because a run whose announcement landed
|
||||||
|
// on the second attempt did have trouble and that stays true for the rest of its
|
||||||
|
// life — and `setHealth` enforces that rather than leaving it to every caller to
|
||||||
|
// remember. `FIELD()` gives the same order inside the WHERE clause, 1-indexed,
|
||||||
|
// which is what makes the guard one statement rather than a read and a write.
|
||||||
|
const HEALTH_ORDER = ['ok', 'degraded', 'stalled']
|
||||||
|
const HEALTH_RANK = Object.fromEntries(HEALTH_ORDER.map((h, i) => [h, i + 1]))
|
||||||
|
const HEALTH_SQL_ORDER = HEALTH_ORDER.map((h) => `'${h}'`).join(', ')
|
||||||
|
|
||||||
// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
|
// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
|
||||||
// pair `park()` alone produces, which means a cue waiting on a human. It is a
|
// pair `park()` alone produces, which means a cue waiting on a human. It is a
|
||||||
// correlated subquery on an admin list bounded at 500 rows rather than a column,
|
// correlated subquery on an admin list bounded at 500 rows rather than a column,
|
||||||
@@ -359,11 +369,20 @@ const statusOf = async (id) => {
|
|||||||
* same degradation does not restamp `updated_at`.
|
* same degradation does not restamp `updated_at`.
|
||||||
*/
|
*/
|
||||||
async function setHealth(id, health) {
|
async function setHealth(id, health) {
|
||||||
const result = await query('UPDATE event_runs SET health = ? WHERE id = ? AND health <> ?', [
|
// **Escalation only, and this is the guard rather than a convention.** Health
|
||||||
health,
|
// has always been a high-water mark here — `degraded` is never cleared,
|
||||||
id,
|
// because a run whose announcement landed on the second attempt DID have
|
||||||
health,
|
// trouble and that stays true — and Phase 5 gave the column a second writer
|
||||||
])
|
// for `stalled`. Without a rank, a step that retried after a stall would
|
||||||
|
// quietly demote `stalled` to `degraded` and a run that waited ninety minutes
|
||||||
|
// on a boss that never came would end its life claiming it merely wobbled.
|
||||||
|
const rank = HEALTH_RANK[health]
|
||||||
|
if (!rank) return false
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE event_runs SET health = ?
|
||||||
|
WHERE id = ? AND FIELD(health, ${HEALTH_SQL_ORDER}) < ?`,
|
||||||
|
[health, id, rank],
|
||||||
|
)
|
||||||
return Number(result?.affectedRows || 0) === 1
|
return Number(result?.affectedRows || 0) === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
const db = require('./eventRuns.db')
|
const db = require('./eventRuns.db')
|
||||||
const stepsDb = require('./eventRunSteps.db')
|
const stepsDb = require('./eventRunSteps.db')
|
||||||
const logDb = require('./eventRunLog.db')
|
const logDb = require('./eventRunLog.db')
|
||||||
|
const gatesDb = require('./eventPhaseGates.db')
|
||||||
|
const gates = require('../../events/gates')
|
||||||
const definitionsDb = require('./eventDefinitions.db')
|
const definitionsDb = require('./eventDefinitions.db')
|
||||||
const versionsDb = require('./eventVersions.db')
|
const versionsDb = require('./eventVersions.db')
|
||||||
|
|
||||||
@@ -140,12 +142,30 @@ async function create(
|
|||||||
return { ok: true, created: true, run: await db.getById(runId) }
|
return { ok: true, created: true, run: await db.getById(runId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A run, its steps and its status counts — what the run console reads. */
|
/**
|
||||||
|
* A run, its steps, its status counts and its phase gates — the run console.
|
||||||
|
*
|
||||||
|
* The gates arrive already DESCRIBED rather than as rows (Phase 5): the panel's
|
||||||
|
* whole value is that it reads the way the condition builder reads, and those
|
||||||
|
* words come from `engagement/conditions.js`'s own operator labels. Rendering
|
||||||
|
* them in the browser would be a second implementation of a grammar the server
|
||||||
|
* owns, and the first clause the two spelled differently would meet its operator
|
||||||
|
* at two in the morning.
|
||||||
|
*
|
||||||
|
* Every gate the run has opened is returned, not only the current phase's. A
|
||||||
|
* completed phase's gate answers "how long did phase 2 actually wait, and what
|
||||||
|
* released it" — which is the same question as the live one, asked afterwards.
|
||||||
|
*/
|
||||||
async function detail(runId) {
|
async function detail(runId) {
|
||||||
const run = await db.getById(runId)
|
const run = await db.getById(runId)
|
||||||
if (!run) return null
|
if (!run) return null
|
||||||
const [steps, counts] = await Promise.all([stepsDb.listForRun(runId), stepsDb.statusCounts(runId)])
|
const [steps, counts, gateRows] = await Promise.all([
|
||||||
return { run, steps, counts }
|
stepsDb.listForRun(runId),
|
||||||
|
stepsDb.statusCounts(runId),
|
||||||
|
gatesDb.listForRun(runId),
|
||||||
|
])
|
||||||
|
const now = new Date()
|
||||||
|
return { run, steps, counts, gates: gateRows.map((g) => gates.describe(g, now)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { create, detail, renderConcurrencyKey }
|
module.exports = { create, detail, renderConcurrencyKey }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
const registries = require('../../../modules/registries')
|
const registries = require('../../../modules/registries')
|
||||||
const spec = require('../../../events/spec')
|
const spec = require('../../../events/spec')
|
||||||
|
const conditionGrammar = require('../../../engagement/conditions')
|
||||||
const definitionsDb = require('../../../model/events/eventDefinitions.db')
|
const definitionsDb = require('../../../model/events/eventDefinitions.db')
|
||||||
const definitions = require('../../../model/events/eventDefinitions.model')
|
const definitions = require('../../../model/events/eventDefinitions.model')
|
||||||
const versionsDb = require('../../../model/events/eventVersions.db')
|
const versionsDb = require('../../../model/events/eventVersions.db')
|
||||||
@@ -146,11 +147,40 @@ exports.catalog = (_req, res) => {
|
|||||||
onFailure: spec.ON_FAILURE,
|
onFailure: spec.ON_FAILURE,
|
||||||
onFailureByRisk: spec.ON_FAILURE_BY_RISK,
|
onFailureByRisk: spec.ON_FAILURE_BY_RISK,
|
||||||
scheduleKinds: spec.SCHEDULE_KINDS,
|
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: {
|
limits: {
|
||||||
maxPhases: spec.MAX_PHASES,
|
maxPhases: spec.MAX_PHASES,
|
||||||
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
|
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
|
||||||
maxSteps: spec.MAX_STEPS,
|
maxSteps: spec.MAX_STEPS,
|
||||||
defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
|
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),
|
run: shapeRun(found.run),
|
||||||
steps: found.steps.map(shapeStep),
|
steps: found.steps.map(shapeStep),
|
||||||
counts: found.counts,
|
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) })
|
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 */
|
/** POST /api/v1/admin/events/runs/:runId/cancel */
|
||||||
exports.cancelRun = async (req, res) => {
|
exports.cancelRun = async (req, res) => {
|
||||||
const runId = asId(req.params.runId)
|
const runId = asId(req.params.runId)
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ eventsRouter.get(
|
|||||||
'/catalog',
|
'/catalog',
|
||||||
// #swagger.tags = ['Admin · Events']
|
// #swagger.tags = ['Admin · Events']
|
||||||
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
|
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
|
||||||
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.'
|
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question.'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'The registered actions and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
|
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
|
||||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
controller.catalog,
|
controller.catalog,
|
||||||
)
|
)
|
||||||
@@ -146,9 +146,9 @@ eventsRouter.get(
|
|||||||
'/runs/:runId',
|
'/runs/:runId',
|
||||||
// #swagger.tags = ['Admin · Events']
|
// #swagger.tags = ['Admin · Events']
|
||||||
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
|
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
|
||||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.'
|
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder's own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human.'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'The run, its steps and the status counts', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true } } } } } } */
|
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts and the phase gates', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
controller.getRun,
|
controller.getRun,
|
||||||
)
|
)
|
||||||
@@ -175,9 +175,11 @@ eventsRouter.get(
|
|||||||
// read consistent, with one role owning both buttons, would behave badly in
|
// read consistent, with one role owning both buttons, would behave badly in
|
||||||
// exactly the case the moderator role exists for.
|
// exactly the case the moderator role exists for.
|
||||||
//
|
//
|
||||||
// `advance` and `cleanup` from the § API surface table are not here: the first
|
// `advance` joined them in Phase 5, which is when it started meaning something:
|
||||||
// has no honest meaning until Phase 5 gives a phase an advance condition, the
|
// a phase with an advance condition can wait on a boss that never spawns, and
|
||||||
// second has no resource ledger to work over until Phase 8.
|
// that is the one state "force it anyway" names. `cleanup` from the § API
|
||||||
|
// surface table is still not here — it has no resource ledger to work over until
|
||||||
|
// Phase 8.
|
||||||
|
|
||||||
eventsRouter.post(
|
eventsRouter.post(
|
||||||
'/runs/:runId/pause',
|
'/runs/:runId/pause',
|
||||||
@@ -220,6 +222,20 @@ eventsRouter.post(
|
|||||||
controller.cancelRun,
|
controller.cancelRun,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
eventsRouter.post(
|
||||||
|
'/runs/:runId/advance',
|
||||||
|
// #swagger.tags = ['Admin · Events']
|
||||||
|
// #swagger.summary = 'Force the current phase past its advance condition'
|
||||||
|
// #swagger.description = 'The other half of the diagnosis panel: a screen that says why a phase has not started, beside the control that does something about it. Legal only while the phase is genuinely waiting on its gate, and the three refusals are the design — a run that is not `running` is waiting on nothing; a phase with no advance condition already advances on its steps; and a phase with a step still open is held by that step, not by its gate, so the step-level skip is the honest control. Satisfies the gate and stops: the next tick performs the phase transition, exactly as it does after resume, so there is only ever one implementation of what a phase boundary is. The log records `because: forced` with the actor, the reason and how long the phase had waited.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Why the condition was overridden. Recorded in the run log with the actor." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'The run, and the phase that was released', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, phase: { type: "string" } } } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'The phase is not waiting on an advance condition', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
liveControl,
|
||||||
|
controller.advanceRunPhase,
|
||||||
|
)
|
||||||
|
|
||||||
eventsRouter.post(
|
eventsRouter.post(
|
||||||
'/runs/:runId/steps/:stepId/confirm',
|
'/runs/:runId/steps/:stepId/confirm',
|
||||||
// #swagger.tags = ['Admin · Events']
|
// #swagger.tags = ['Admin · Events']
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
const registries = require('../modules/registries')
|
const registries = require('../modules/registries')
|
||||||
const engine = require('../engagement/engine')
|
const engine = require('../engagement/engine')
|
||||||
|
const eventGates = require('../events/gates')
|
||||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||||
const createLogger = require('./logger')
|
const createLogger = require('./logger')
|
||||||
|
|
||||||
@@ -276,6 +277,20 @@ function emit(owner, triggerId, envelope = {}) {
|
|||||||
// await the delivery decision, and the tests use it directly.
|
// await the delivery decision, and the tests use it directly.
|
||||||
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
|
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
|
||||||
|
|
||||||
|
// **The trigger stream's second subscriber** (EVENTS.md §E, Phase 5). An event
|
||||||
|
// run whose phase is waiting on `{ on: '<triggerId>', count: n }` counts this
|
||||||
|
// firing here, at the moment it fires, because nothing observable survives to
|
||||||
|
// the runner's next tick. Same seam, same posture: not awaited, never allowed
|
||||||
|
// to reject, and it knows nothing about who emitted.
|
||||||
|
//
|
||||||
|
// It is a SECOND subscriber and not a leg of `dispatch` because the two
|
||||||
|
// decide different things — who gets told, and whether a phase may proceed —
|
||||||
|
// and neither must be able to fail the other. A rules lookup that throws must
|
||||||
|
// not lose the count, and a gate write that throws must not lose the mail.
|
||||||
|
eventGates
|
||||||
|
.observe(event)
|
||||||
|
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
|
||||||
|
|
||||||
return { ok: true, event }
|
return { ok: true, event }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
// 3. **advance** — claim each due run and move it through its phases
|
// 3. **advance** — claim each due run and move it through its phases
|
||||||
// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
|
// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
|
||||||
//
|
//
|
||||||
|
// **What a phase advances on, as of Phase 5.** Every step terminal, and — if the
|
||||||
|
// phase authored one — its GATE open as well. The gate is an ADDITIONAL
|
||||||
|
// condition and never a replacement: a phase whose steps are still running is
|
||||||
|
// not advanced by a boss that spawned early. `{ after: '30m' }` is closed by
|
||||||
|
// this file when its deadline passes; `{ on: '<trigger>', count: n }` is closed
|
||||||
|
// by the EMIT PATH, because a firing between two ticks is not observable from
|
||||||
|
// either of them. See `events/gates.js` for that division.
|
||||||
|
//
|
||||||
// **What "materialise" means, and why it is two halves.** Phase 4 completed it.
|
// **What "materialise" means, and why it is two halves.** Phase 4 completed it.
|
||||||
// The first half EXPANDS: every `ready` definition's recurrence is computed in
|
// The first half EXPANDS: every `ready` definition's recurrence is computed in
|
||||||
// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a
|
// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a
|
||||||
@@ -55,7 +63,10 @@ const logDb = require('../model/events/eventRunLog.db')
|
|||||||
const versionsDb = require('../model/events/eventVersions.db')
|
const versionsDb = require('../model/events/eventVersions.db')
|
||||||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||||||
const runsModel = require('../model/events/eventRuns.model')
|
const runsModel = require('../model/events/eventRuns.model')
|
||||||
|
const gatesDb = require('../model/events/eventPhaseGates.db')
|
||||||
const recurrence = require('../events/recurrence')
|
const recurrence = require('../events/recurrence')
|
||||||
|
const gates = require('../events/gates')
|
||||||
|
const spec = require('../events/spec')
|
||||||
const registries = require('../modules/registries')
|
const registries = require('../modules/registries')
|
||||||
const { dispatchStep } = require('../events/dispatch')
|
const { dispatchStep } = require('../events/dispatch')
|
||||||
const log = require('./logger')('event-runner')
|
const log = require('./logger')('event-runner')
|
||||||
@@ -267,6 +278,89 @@ async function drainStep(run, step, now, carry = {}) {
|
|||||||
return applyFailure(run, step, result.error)
|
return applyFailure(run, step, result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a phase's advance gate, if it authored one.
|
||||||
|
*
|
||||||
|
* Called at phase entry, immediately after `materialisePhase` and BEFORE the
|
||||||
|
* transition that makes the phase current — which is the safe order rather than
|
||||||
|
* the tidy one. A process that died between the transition and this call would
|
||||||
|
* leave a phase whose gate does not exist, and a missing gate does not hold a
|
||||||
|
* phase: it advances on its steps alone, silently ignoring the condition its
|
||||||
|
* author wrote. Opening first risks only a row for a phase this tick did not win,
|
||||||
|
* which the winner's INSERT IGNORE then finds already correct.
|
||||||
|
*/
|
||||||
|
async function openGate(runId, phase, now) {
|
||||||
|
const advance = phase?.advance
|
||||||
|
if (!advance) return false
|
||||||
|
|
||||||
|
const created =
|
||||||
|
advance.after !== undefined
|
||||||
|
? await gatesDb.open({
|
||||||
|
runId,
|
||||||
|
phase: phase.key,
|
||||||
|
kind: 'after',
|
||||||
|
// Re-derived from the authored string rather than stored beside it:
|
||||||
|
// `events/spec.js` owns what `'2h'` means, and this is the one caller
|
||||||
|
// that needs the number.
|
||||||
|
afterSeconds: spec.parseAfter(advance.after),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
: await gatesDb.open({
|
||||||
|
runId,
|
||||||
|
phase: phase.key,
|
||||||
|
kind: 'on',
|
||||||
|
triggerId: advance.on,
|
||||||
|
conditions: advance.where ?? null,
|
||||||
|
needed: advance.count || 1,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
await logDb.write({
|
||||||
|
runId,
|
||||||
|
kind: 'phase.gate',
|
||||||
|
phase: phase.key,
|
||||||
|
detail:
|
||||||
|
advance.after !== undefined
|
||||||
|
? { kind: 'after', after: advance.after }
|
||||||
|
: { kind: 'on', trigger: advance.on, needed: advance.count || 1, where: gates.phrase(advance.where ?? null) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run whose phase has waited past `EVENT_PHASE_STALL_MS` is `stalled`.
|
||||||
|
*
|
||||||
|
* §E's third health value, and the first thing in this system ever to write it.
|
||||||
|
* It is VISIBILITY and not a timeout: nothing advances, nothing fails, and a
|
||||||
|
* human decides — which is the org lead's answer of 2026-09-02 and the reason
|
||||||
|
* there is no authored deadline in the spec. What it must not be is quiet,
|
||||||
|
* because a held run also holds its concurrency key, so every later occurrence
|
||||||
|
* of the same definition goes `missed` behind it.
|
||||||
|
*
|
||||||
|
* `setHealth` only ever escalates, so this cannot undo a `degraded` a retry
|
||||||
|
* earned, and the `run.health` line is written once because `setHealth` answers
|
||||||
|
* whether it changed anything.
|
||||||
|
*/
|
||||||
|
async function noteStall(run, gate, now) {
|
||||||
|
const described = gates.describe(gate, now)
|
||||||
|
if (!described?.stalled) return false
|
||||||
|
if (!(await runsDb.setHealth(run.id, 'stalled'))) return false
|
||||||
|
await logDb.write({
|
||||||
|
runId: run.id,
|
||||||
|
kind: 'run.health',
|
||||||
|
phase: gate.phase,
|
||||||
|
detail: {
|
||||||
|
to: 'stalled',
|
||||||
|
because: `waiting ${described.elapsedSeconds}s on ${gate.trigger_id}`,
|
||||||
|
seen: gate.tally,
|
||||||
|
needed: gate.needed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance one claimed run as far as it will go this tick.
|
* Advance one claimed run as far as it will go this tick.
|
||||||
*
|
*
|
||||||
@@ -297,6 +391,7 @@ async function advanceRun(run, now) {
|
|||||||
// died between the claim and here uneventful.
|
// died between the claim and here uneventful.
|
||||||
const first = phases[0]
|
const first = phases[0]
|
||||||
await stepsDb.materialisePhase(run.id, first.key, first.steps || [])
|
await stepsDb.materialisePhase(run.id, first.key, first.steps || [])
|
||||||
|
await openGate(run.id, first, now)
|
||||||
if (!(await runsDb.transition(run.id, 'starting', 'running', { phase: first.key }))) return 'taken'
|
if (!(await runsDb.transition(run.id, 'starting', 'running', { phase: first.key }))) return 'taken'
|
||||||
phaseKey = first.key
|
phaseKey = first.key
|
||||||
await logDb.write({ runId: run.id, kind: 'run.status', phase: first.key, detail: { from: 'starting', to: 'running' } })
|
await logDb.write({ runId: run.id, kind: 'run.status', phase: first.key, detail: { from: 'starting', to: 'running' } })
|
||||||
@@ -342,7 +437,39 @@ async function advanceRun(run, now) {
|
|||||||
return outcome === 'taken' ? 'taken' : 'stopped'
|
return outcome === 'taken' ? 'taken' : 'stopped'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every step of this phase is terminal.
|
// Every step of this phase is terminal — which is the whole of the advance
|
||||||
|
// test for a phase with no gate, and half of it for a phase with one.
|
||||||
|
const { open, gate } = await gates.check(run.id, phaseKey, now)
|
||||||
|
if (!open) {
|
||||||
|
await noteStall(run, gate, now)
|
||||||
|
// **Nothing is logged per tick here, deliberately.** The gate row IS the
|
||||||
|
// state — the tally, the deadline, the last related event — and the run
|
||||||
|
// console reads it directly. A `phase.waiting` line every fifteen seconds
|
||||||
|
// would bury `condition.evaluated`, which is the line that actually says
|
||||||
|
// something happened.
|
||||||
|
return 'waiting'
|
||||||
|
}
|
||||||
|
// **`phase.advanced` is written by whoever made the DECISION, and a forced
|
||||||
|
// gate's decision was not this tick's.** The emit path closes an `on` gate
|
||||||
|
// and logs only `condition.evaluated`, so the line for that one is the
|
||||||
|
// runner's; `gates.check` closes an `after` gate here, so that one is too.
|
||||||
|
// A human closing it through the advance control already wrote the line,
|
||||||
|
// with the actor and the reason — things this tick does not have — and a
|
||||||
|
// second line from here made the console show the phase advancing twice,
|
||||||
|
// the less informative one last. Found in the live walk.
|
||||||
|
if (gate && gate.satisfied_by !== 'forced') {
|
||||||
|
await logDb.write({
|
||||||
|
runId: run.id,
|
||||||
|
kind: 'phase.advanced',
|
||||||
|
phase: phaseKey,
|
||||||
|
detail: {
|
||||||
|
because: gate.satisfied_by,
|
||||||
|
waitedSeconds: Math.max(0, Math.round((new Date(gate.satisfied_at) - new Date(gate.entered_at)) / 1000)),
|
||||||
|
...(gate.kind === 'on' ? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed } : { after: gate.after_seconds }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await logDb.write({ runId: run.id, kind: 'phase.completed', phase: phaseKey, detail: { index: phaseIndex } })
|
await logDb.write({ runId: run.id, kind: 'phase.completed', phase: phaseKey, detail: { index: phaseIndex } })
|
||||||
|
|
||||||
const next = phases[phaseIndex + 1]
|
const next = phases[phaseIndex + 1]
|
||||||
@@ -358,6 +485,7 @@ async function advanceRun(run, now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await stepsDb.materialisePhase(run.id, next.key, next.steps || [])
|
await stepsDb.materialisePhase(run.id, next.key, next.steps || [])
|
||||||
|
await openGate(run.id, next, now)
|
||||||
if (carry.holdUntil) {
|
if (carry.holdUntil) {
|
||||||
// `seq > -1` is the first step of the phase just created. Applied after
|
// `seq > -1` is the first step of the phase just created. Applied after
|
||||||
// materialisation because that is the first moment there is a row to hold.
|
// materialisation because that is the first moment there is a row to hold.
|
||||||
|
|||||||
@@ -3871,7 +3871,7 @@
|
|||||||
"Admin · Events"
|
"Admin · Events"
|
||||||
],
|
],
|
||||||
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
|
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
|
||||||
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.",
|
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "runId",
|
"name": "runId",
|
||||||
@@ -3884,7 +3884,7 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "The run, its steps and the status counts",
|
"description": "The run, its steps, the status counts and the phase gates",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -3904,6 +3904,13 @@
|
|||||||
"counts": {
|
"counts": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"gates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3934,6 +3941,101 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/events/runs/{runId}/advance": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Events"
|
||||||
|
],
|
||||||
|
"summary": "Force the current phase past its advance condition",
|
||||||
|
"description": "The other half of the diagnosis panel: a screen that says why a phase has not started, beside the control that does something about it. Legal only while the phase is genuinely waiting on its gate, and the three refusals are the design — a run that is not `running` is waiting on nothing; a phase with no advance condition already advances on its steps; and a phase with a step still open is held by that step, not by its gate, so the step-level skip is the honest control. Satisfies the gate and stops: the next tick performs the phase transition, exactly as it does after resume, so there is only ever one implementation of what a phase boundary is. The log records `because: forced` with the actor, the reason and how long the phase had waited.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "runId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The run, and the phase that was released",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"run": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not an admin or moderator",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "The phase is not waiting on an advance condition",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"errors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": false,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why the condition was overridden. Recorded in the run log with the actor."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/events/runs/{runId}/cancel": {
|
"/api/v1/admin/events/runs/{runId}/cancel": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
267
server/test/eventGates.test.js
Normal file
267
server/test/eventGates.test.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// ── Phase advance gates (EVENTS_PLAN.md Phase 5) ───────────────────────────
|
||||||
|
//
|
||||||
|
// The runner's half of this is in `eventRunner.test.js`, where a gate holds a
|
||||||
|
// phase and lets it go. This file is the other half — the two things that only
|
||||||
|
// exist because an operator has to READ them:
|
||||||
|
//
|
||||||
|
// • `phrase()`, which renders a condition tree in the CONDITION BUILDER's own
|
||||||
|
// words. § Observability's claim is that `gte` says "is at least" on the
|
||||||
|
// diagnosis panel because it says "is at least" in the rule editor. That is
|
||||||
|
// a claim about two files agreeing, so it is tested against the grammar's
|
||||||
|
// own labels rather than against a string this file wrote down.
|
||||||
|
// • `observe()`, whose whole reason for existing is that a firing between two
|
||||||
|
// ticks is not observable from either of them — and whose near-miss branch
|
||||||
|
// is the more valuable of its two outcomes on the night.
|
||||||
|
//
|
||||||
|
// **`describe()` is tested for what it does NOT say as much as what it does.**
|
||||||
|
// An `after` gate is never stalled, however long it was authored to wait: a
|
||||||
|
// phase waiting out six hours it was told to wait is working, and health that
|
||||||
|
// said otherwise would train an operator to ignore it.
|
||||||
|
//
|
||||||
|
// Point the DB at a closed port before requiring anything.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const gates = require('../src/events/gates')
|
||||||
|
const conditions = require('../src/engagement/conditions')
|
||||||
|
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||||
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const T0 = new Date('2026-09-02T20:31:04Z')
|
||||||
|
|
||||||
|
// ── phrase(): the words are the grammar's, not this file's ─────────────────
|
||||||
|
|
||||||
|
test('every operator renders with the label the condition grammar declares', () => {
|
||||||
|
// Not a table of expected strings: that would be a second copy of the labels,
|
||||||
|
// and the point of rendering server-side is that there is only one.
|
||||||
|
for (const [cmp, operator] of Object.entries(conditions.OPERATORS)) {
|
||||||
|
const leaf =
|
||||||
|
operator.arity === 0
|
||||||
|
? { variable: 'region', cmp }
|
||||||
|
: operator.arity === 'list'
|
||||||
|
? { variable: 'region', cmp, value: ['Yew', 'Britain'] }
|
||||||
|
: { variable: 'region', cmp, value: 'Yew' }
|
||||||
|
const rendered = gates.phrase(leaf)
|
||||||
|
assert.ok(rendered.startsWith(`region ${operator.label}`), `${cmp} should read as "${operator.label}"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a tree reads as a sentence, and only brackets where the reading changes', () => {
|
||||||
|
assert.equal(gates.phrase({ variable: 'region', cmp: 'eq', value: 'Yew' }), 'region is "Yew"')
|
||||||
|
assert.equal(
|
||||||
|
gates.phrase({ op: 'and', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }, { variable: 'level', cmp: 'gte', value: 3 }] }),
|
||||||
|
'region is "Yew" and level is at least 3',
|
||||||
|
'a flat and is a sentence, not a nest of brackets',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
gates.phrase({
|
||||||
|
op: 'or',
|
||||||
|
nodes: [
|
||||||
|
{ op: 'and', nodes: [{ variable: 'a', cmp: 'present' }, { variable: 'b', cmp: 'in', value: ['x', 'y'] }] },
|
||||||
|
{ variable: 'c', cmp: 'absent' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'(a is present and b is one of "x", "y") or c is absent',
|
||||||
|
)
|
||||||
|
assert.equal(gates.phrase({ op: 'not', nodes: [{ variable: 'region', cmp: 'eq', value: 'Yew' }] }), 'not (region is "Yew")')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no conditions renders as nothing, not as "always true"', () => {
|
||||||
|
// The caller says "on any firing of this trigger"; a clause claiming
|
||||||
|
// everything is true is one more thing to read past.
|
||||||
|
assert.equal(gates.phrase(null), null)
|
||||||
|
assert.equal(gates.phrase({ variable: 'x', cmp: 'nonsense', value: 1 }), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('variablesIn names each variable once, in the order the tree names them', () => {
|
||||||
|
const tree = {
|
||||||
|
op: 'and',
|
||||||
|
nodes: [
|
||||||
|
{ variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||||
|
{ op: 'or', nodes: [{ variable: 'level', cmp: 'gte', value: 3 }, { variable: 'region', cmp: 'ne', value: 'Britain' }] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert.deepEqual(gates.variablesIn(tree), ['region', 'level'])
|
||||||
|
assert.deepEqual(gates.variablesIn(null), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── describe(): what the panel shows ───────────────────────────────────────
|
||||||
|
|
||||||
|
const gateRow = (over = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
run_id: 1,
|
||||||
|
phase: 'boss',
|
||||||
|
kind: 'on',
|
||||||
|
after_seconds: null,
|
||||||
|
trigger_id: 'uo.champ.boss_up',
|
||||||
|
conditions: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||||
|
needed: 1,
|
||||||
|
tally: 0,
|
||||||
|
entered_at: T0,
|
||||||
|
due_at: null,
|
||||||
|
last_event: null,
|
||||||
|
last_event_at: null,
|
||||||
|
satisfied_at: null,
|
||||||
|
satisfied_by: null,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the panel answers "why didn\'t phase 3 start?" with the tally, the clock and the clause', () => {
|
||||||
|
const at = new Date(T0.getTime() + 28 * 60_000)
|
||||||
|
const described = gates.describe(gateRow(), at)
|
||||||
|
assert.equal(described.phase, 'boss')
|
||||||
|
assert.equal(described.waitingOn, 'uo.champ.boss_up')
|
||||||
|
assert.equal(described.where, 'region is "Yew"')
|
||||||
|
assert.equal(described.seen, 0)
|
||||||
|
assert.equal(described.needed, 1)
|
||||||
|
assert.equal(described.elapsedSeconds, 28 * 60)
|
||||||
|
assert.equal(described.satisfied, false)
|
||||||
|
assert.equal(described.stalled, false, '28 minutes is not yet a stall')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a satisfied gate stops its clock at the moment it was satisfied', () => {
|
||||||
|
// Live, `elapsedSeconds` answers "how long has this phase been waiting";
|
||||||
|
// afterwards it answers "how long did it wait", and those are the same number
|
||||||
|
// only while it is still waiting. The walk found it disagreeing with
|
||||||
|
// `phase.advanced`'s own `waitedSeconds` by the age of the open screen.
|
||||||
|
const gate = gateRow({ satisfied_at: new Date(T0.getTime() + 121_000), satisfied_by: 'forced' })
|
||||||
|
const muchLater = new Date(T0.getTime() + 3 * 3600_000)
|
||||||
|
assert.equal(gates.describe(gate, muchLater).elapsedSeconds, 121)
|
||||||
|
// And an open one still measures to now.
|
||||||
|
assert.equal(gates.describe(gateRow(), new Date(T0.getTime() + 300_000)).elapsedSeconds, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `after` gate is never stalled, however long it was told to wait', () => {
|
||||||
|
const gate = gateRow({
|
||||||
|
kind: 'after',
|
||||||
|
trigger_id: null,
|
||||||
|
conditions: null,
|
||||||
|
after_seconds: 6 * 3600,
|
||||||
|
due_at: new Date(T0.getTime() + 6 * 3600_000),
|
||||||
|
})
|
||||||
|
const described = gates.describe(gate, new Date(T0.getTime() + gates.STALL_MS * 4))
|
||||||
|
assert.equal(described.stalled, false)
|
||||||
|
assert.equal(described.after, 6 * 3600)
|
||||||
|
assert.equal(described.waitingOn, undefined, 'and it carries none of the `on` fields')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `on` gate is stalled once it has waited past the threshold, and never once satisfied', () => {
|
||||||
|
const late = new Date(T0.getTime() + gates.STALL_MS + 1000)
|
||||||
|
assert.equal(gates.describe(gateRow(), late).stalled, true)
|
||||||
|
assert.equal(gates.describe(gateRow({ satisfied_at: T0, satisfied_by: 'condition' }), late).stalled, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── observe(): the emit path ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
let store
|
||||||
|
|
||||||
|
function installStubs() {
|
||||||
|
store = { gates: [], log: [] }
|
||||||
|
gatesDb.openForTrigger = async (triggerId) => store.gates.filter((g) => g.trigger_id === triggerId && !g.satisfied_at)
|
||||||
|
gatesDb.count = async (id, { lastEvent, now }) => {
|
||||||
|
const g = store.gates.find((x) => x.id === id)
|
||||||
|
if (!g || g.satisfied_at) return { counted: false, satisfied: false }
|
||||||
|
g.tally += 1
|
||||||
|
g.last_event = lastEvent
|
||||||
|
g.last_event_at = now
|
||||||
|
if (g.tally >= g.needed) g.satisfied_at = now
|
||||||
|
return { counted: true, satisfied: Boolean(g.satisfied_at), tally: g.tally }
|
||||||
|
}
|
||||||
|
gatesDb.noteNearMiss = async (id, { lastEvent, now }) => {
|
||||||
|
const g = store.gates.find((x) => x.id === id)
|
||||||
|
if (!g) return false
|
||||||
|
g.last_event = lastEvent
|
||||||
|
g.last_event_at = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
logDb.write = async (line) => {
|
||||||
|
store.log.push(line)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = [
|
||||||
|
[gatesDb, { ...gatesDb }],
|
||||||
|
[logDb, { ...logDb }],
|
||||||
|
]
|
||||||
|
beforeEach(installStubs)
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [mod, fns] of originals) Object.assign(mod, fns)
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = (data, triggerId = 'uo.champ.boss_up') =>
|
||||||
|
gates.observe({ triggerId, occurredAt: T0.toISOString(), subject: 'champ:yew', data })
|
||||||
|
|
||||||
|
test('a matching firing counts, and a near miss is recorded without counting', async () => {
|
||||||
|
store.gates.push(gateRow({ needed: 2 }))
|
||||||
|
|
||||||
|
await emit({ region: 'Britain', level: 4 })
|
||||||
|
assert.equal(store.gates[0].tally, 0)
|
||||||
|
assert.equal(store.gates[0].last_event.matched, false)
|
||||||
|
|
||||||
|
await emit({ region: 'Yew', level: 4 })
|
||||||
|
assert.equal(store.gates[0].tally, 1)
|
||||||
|
assert.equal(store.gates[0].satisfied_at, null)
|
||||||
|
|
||||||
|
await emit({ region: 'Yew', level: 1 })
|
||||||
|
assert.equal(store.gates[0].tally, 2)
|
||||||
|
assert.ok(store.gates[0].satisfied_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only the variables the condition names are recorded, never the payload', async () => {
|
||||||
|
// The row is read back onto an admin screen, and a copy of a whole game
|
||||||
|
// event's data is a second copy of exactly the content `engagement_sends` is
|
||||||
|
// careful not to keep.
|
||||||
|
store.gates.push(gateRow())
|
||||||
|
await emit({ region: 'Yew', playerName: 'Dupre', houseLocation: '1423,1712', level: 4 })
|
||||||
|
|
||||||
|
assert.deepEqual(store.gates[0].last_event.variables, { region: 'Yew' })
|
||||||
|
const line = store.log.find((l) => l.kind === 'condition.evaluated')
|
||||||
|
assert.deepEqual(line.detail.variables, { region: 'Yew' })
|
||||||
|
assert.equal(JSON.stringify(store.log).includes('Dupre'), false)
|
||||||
|
assert.equal(JSON.stringify(store.gates).includes('1423,1712'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('both outcomes are logged, with the tally the row actually holds', async () => {
|
||||||
|
store.gates.push(gateRow({ needed: 2 }))
|
||||||
|
await emit({ region: 'Britain' })
|
||||||
|
await emit({ region: 'Yew' })
|
||||||
|
|
||||||
|
const lines = store.log.filter((l) => l.kind === 'condition.evaluated')
|
||||||
|
assert.equal(lines.length, 2)
|
||||||
|
assert.deepEqual(lines.map((l) => l.detail.matched), [false, true])
|
||||||
|
assert.deepEqual(lines.map((l) => l.detail.seen), [0, 1])
|
||||||
|
assert.deepEqual(lines.map((l) => l.detail.satisfied), [false, false])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate with no `where` counts every firing of its trigger', async () => {
|
||||||
|
store.gates.push(gateRow({ conditions: null }))
|
||||||
|
await emit({ anything: true })
|
||||||
|
assert.equal(store.gates[0].tally, 1)
|
||||||
|
assert.deepEqual(store.gates[0].last_event.variables, {}, 'and there are no named variables to record')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a firing of another trigger touches nothing', async () => {
|
||||||
|
store.gates.push(gateRow())
|
||||||
|
const summary = await emit({ region: 'Yew' }, 'uo.champ.started')
|
||||||
|
assert.equal(summary.gates, 0)
|
||||||
|
assert.equal(store.gates[0].tally, 0)
|
||||||
|
assert.equal(store.log.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('observe never rejects, however badly the database is behaving', async () => {
|
||||||
|
// It is called from inside a game-event handler by way of `ctx.events.emit`,
|
||||||
|
// exactly as `engine.dispatch` is. A database problem of core's must not
|
||||||
|
// become a module's control flow at three in the morning.
|
||||||
|
gatesDb.openForTrigger = async () => {
|
||||||
|
throw new Error('pool exhausted')
|
||||||
|
}
|
||||||
|
const summary = await emit({ region: 'Yew' })
|
||||||
|
assert.deepEqual(summary, { gates: 0, counted: 0, satisfied: 0 })
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// ── The live run controls (EVENTS_PLAN.md Phase 3) ─────────────────────────
|
// ── The live run controls (EVENTS_PLAN.md Phase 3) ─────────────────────────
|
||||||
//
|
//
|
||||||
// Six controls, and what is tested is almost entirely the REFUSALS. A control
|
// Seven controls as of Phase 5, and what is tested is almost entirely the
|
||||||
|
// REFUSALS. A control
|
||||||
// that works is easy; a control that works from a status it should not have
|
// that works is easy; a control that works from a status it should not have
|
||||||
// worked from is a staff member changing a live game world by pressing a button
|
// worked from is a staff member changing a live game world by pressing a button
|
||||||
// a stale screen offered them. So each of the six is exercised from every status
|
// a stale screen offered them. So each of the six is exercised from every status
|
||||||
@@ -13,6 +14,8 @@
|
|||||||
// • confirm on a step a process is mid-dispatch on, not a parked cue
|
// • confirm on a step a process is mid-dispatch on, not a parked cue
|
||||||
// • skip on a step with a live lease
|
// • skip on a step with a live lease
|
||||||
// • cancel closing out a parked cue, so a cancelled run stops "waiting"
|
// • cancel closing out a parked cue, so a cancelled run stops "waiting"
|
||||||
|
// • advance on a phase that is NOT waiting on its gate — the refusal that
|
||||||
|
// makes force-advance an override rather than a way to skip a phase's steps
|
||||||
//
|
//
|
||||||
// The three tables are stubbed at the `.db` layer and the model's own logic runs
|
// The three tables are stubbed at the `.db` layer and the model's own logic runs
|
||||||
// for real against them — the shape `eventRunner.test.js` uses. What a stub
|
// for real against them — the shape `eventRunner.test.js` uses. What a stub
|
||||||
@@ -31,6 +34,7 @@ const controls = require('../src/model/events/eventRunControls.model')
|
|||||||
const runsDb = require('../src/model/events/eventRuns.db')
|
const runsDb = require('../src/model/events/eventRuns.db')
|
||||||
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||||
const logDb = require('../src/model/events/eventRunLog.db')
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
|
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
after(() => db.close())
|
after(() => db.close())
|
||||||
@@ -43,10 +47,11 @@ const originals = [
|
|||||||
['runs', runsDb, { ...runsDb }],
|
['runs', runsDb, { ...runsDb }],
|
||||||
['steps', stepsDb, { ...stepsDb }],
|
['steps', stepsDb, { ...stepsDb }],
|
||||||
['log', logDb, { ...logDb }],
|
['log', logDb, { ...logDb }],
|
||||||
|
['gates', gatesDb, { ...gatesDb }],
|
||||||
]
|
]
|
||||||
|
|
||||||
function installStubs() {
|
function installStubs() {
|
||||||
store = { runs: new Map(), steps: new Map(), log: [], nextStepId: 1 }
|
store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), nextStepId: 1, nextGateId: 1 }
|
||||||
const snap = (o) => ({ ...o })
|
const snap = (o) => ({ ...o })
|
||||||
|
|
||||||
runsDb.getById = async (id) => {
|
runsDb.getById = async (id) => {
|
||||||
@@ -111,6 +116,18 @@ function installStubs() {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5's advance guard reads this. Unstubbed it is a real query, and this
|
||||||
|
// file points the pool at a closed port — three tests would hang for ten
|
||||||
|
// seconds each and then fail with ECONNREFUSED rather than saying anything
|
||||||
|
// about the control. Same shape as the statement: pending and running only,
|
||||||
|
// lowest seq first.
|
||||||
|
stepsDb.nextOpenStep = async (runId, phase) => {
|
||||||
|
const s = [...store.steps.values()]
|
||||||
|
.filter((x) => x.run_id === Number(runId) && x.phase === phase && ['pending', 'running'].includes(x.status))
|
||||||
|
.sort((a, b) => a.seq - b.seq || a.id - b.id)[0]
|
||||||
|
return s ? { ...s } : null
|
||||||
|
}
|
||||||
|
|
||||||
stepsDb.lastStartedSeq = async (runId, phase) => {
|
stepsDb.lastStartedSeq = async (runId, phase) => {
|
||||||
const started = [...store.steps.values()]
|
const started = [...store.steps.values()]
|
||||||
.filter((s) => s.run_id === Number(runId) && s.phase === phase && s.status !== 'pending')
|
.filter((s) => s.run_id === Number(runId) && s.phase === phase && s.status !== 'pending')
|
||||||
@@ -122,6 +139,19 @@ function installStubs() {
|
|||||||
store.log.push(line)
|
store.log.push(line)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5. `satisfy` carries `WHERE satisfied_at IS NULL`, and the stub keeps
|
||||||
|
// it: a gate that could be forced twice would log two advances of one phase.
|
||||||
|
gatesDb.forPhase = async (runId, phase) => {
|
||||||
|
const g = store.gates.get(`${Number(runId)}|${phase}`)
|
||||||
|
return g ? { ...g } : null
|
||||||
|
}
|
||||||
|
gatesDb.satisfy = async (id, by, { userId = null, now = new Date() } = {}) => {
|
||||||
|
const g = [...store.gates.values()].find((x) => x.id === id)
|
||||||
|
if (!g || g.satisfied_at) return false
|
||||||
|
Object.assign(g, { satisfied_at: now, satisfied_by: by, forced_by: userId })
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(installStubs)
|
beforeEach(installStubs)
|
||||||
@@ -131,6 +161,29 @@ afterEach(() => {
|
|||||||
|
|
||||||
let nextRunId = 1
|
let nextRunId = 1
|
||||||
|
|
||||||
|
function seedGate(runId, phase, { kind = 'on', trigger = 'test.trigger', needed = 1, tally = 0, minutesAgo = 5 } = {}) {
|
||||||
|
const g = {
|
||||||
|
id: store.nextGateId++,
|
||||||
|
run_id: runId,
|
||||||
|
phase,
|
||||||
|
kind,
|
||||||
|
after_seconds: kind === 'after' ? 1800 : null,
|
||||||
|
trigger_id: kind === 'on' ? trigger : null,
|
||||||
|
conditions: null,
|
||||||
|
needed,
|
||||||
|
tally,
|
||||||
|
entered_at: new Date(Date.now() - minutesAgo * 60_000),
|
||||||
|
due_at: null,
|
||||||
|
last_event: null,
|
||||||
|
last_event_at: null,
|
||||||
|
satisfied_at: null,
|
||||||
|
satisfied_by: null,
|
||||||
|
forced_by: null,
|
||||||
|
}
|
||||||
|
store.gates.set(`${runId}|${phase}`, g)
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
function seedRun({ status = 'running', phase = 'main', steps = [] } = {}) {
|
function seedRun({ status = 'running', phase = 'main', steps = [] } = {}) {
|
||||||
const id = nextRunId++
|
const id = nextRunId++
|
||||||
store.runs.set(id, {
|
store.runs.set(id, {
|
||||||
@@ -168,6 +221,72 @@ const runRow = (id) => store.runs.get(id)
|
|||||||
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
|
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
|
||||||
const lastLog = () => store.log[store.log.length - 1]
|
const lastLog = () => store.log[store.log.length - 1]
|
||||||
|
|
||||||
|
// ── advance (Phase 5) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('advance releases a phase that is genuinely waiting on its gate', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||||
|
const gate = seedGate(id, 'main', { needed: 3, tally: 1, minutesAgo: 68 })
|
||||||
|
|
||||||
|
const result = await controls.advancePhase(id, { reason: 'the boss never spawned' }, ACTOR)
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.phase, 'main')
|
||||||
|
assert.equal(gate.satisfied_by, 'forced')
|
||||||
|
assert.equal(gate.forced_by, ACTOR)
|
||||||
|
|
||||||
|
// The run is NOT transitioned here: the next tick does the phase boundary,
|
||||||
|
// exactly as it does after resume, so there is one implementation of what a
|
||||||
|
// phase boundary is rather than two.
|
||||||
|
assert.equal(runRow(id).current_phase, 'main')
|
||||||
|
|
||||||
|
const line = lastLog()
|
||||||
|
assert.equal(line.kind, 'phase.advanced')
|
||||||
|
assert.equal(line.detail.because, 'forced')
|
||||||
|
assert.equal(line.detail.by, ACTOR)
|
||||||
|
assert.equal(line.detail.reason, 'the boss never spawned')
|
||||||
|
assert.equal(line.detail.seen, 1)
|
||||||
|
assert.equal(line.detail.needed, 3, 'and the log says what it was still waiting for')
|
||||||
|
assert.ok(line.detail.waitedSeconds > 4000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance is refused on a phase that is waiting on a STEP, not on its gate', async () => {
|
||||||
|
// The refusal that makes this an override rather than a way to skip work: a
|
||||||
|
// phase with an open step is held by the step, and skip is its control.
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }, { status: 'pending', actionId: 'test.slow' }] })
|
||||||
|
seedGate(id, 'main')
|
||||||
|
|
||||||
|
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 409)
|
||||||
|
assert.match(result.errors[0], /waiting on step 1 \(test\.slow\)/)
|
||||||
|
assert.equal(store.log.length, 0, 'and nothing is logged for a refusal')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance is refused when the phase has no advance condition at all', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||||
|
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.errors[0], /has no advance condition; skip its steps instead/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance is refused from every status that is not `running`', async () => {
|
||||||
|
for (const status of ['scheduled', 'starting', 'paused', 'ending', ...TERMINAL]) {
|
||||||
|
const id = seedRun({ status, steps: [{ status: 'done' }] })
|
||||||
|
seedGate(id, 'main')
|
||||||
|
const result = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(result.ok, false, `a ${status} run should not be advanceable`)
|
||||||
|
assert.match(result.errors[0], new RegExp(`a ${status} run has no phase to advance`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('advance twice is refused the second time', async () => {
|
||||||
|
const id = seedRun({ status: 'running', steps: [{ status: 'done' }] })
|
||||||
|
seedGate(id, 'main')
|
||||||
|
assert.equal((await controls.advancePhase(id, {}, ACTOR)).ok, true)
|
||||||
|
const second = await controls.advancePhase(id, {}, ACTOR)
|
||||||
|
assert.equal(second.ok, false)
|
||||||
|
assert.match(second.errors[0], /already past its advance condition/)
|
||||||
|
})
|
||||||
|
|
||||||
// ── pause / resume ─────────────────────────────────────────────────────────
|
// ── pause / resume ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('pause takes a run in flight and records who did it', async () => {
|
test('pause takes a run in flight and records who did it', async () => {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const stepsDb = require('../src/model/events/eventRunSteps.db')
|
|||||||
const logDb = require('../src/model/events/eventRunLog.db')
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
const versionsDb = require('../src/model/events/eventVersions.db')
|
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||||
|
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||||
|
const gates = require('../src/events/gates')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
after(() => db.close())
|
after(() => db.close())
|
||||||
@@ -48,7 +50,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
|
|||||||
let store
|
let store
|
||||||
const originals = {}
|
const originals = {}
|
||||||
|
|
||||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb]]) {
|
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb]]) {
|
||||||
originals[name] = { mod, fns: { ...mod } }
|
originals[name] = { mod, fns: { ...mod } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,9 @@ function installStubs() {
|
|||||||
log: [],
|
log: [],
|
||||||
versions: new Map(),
|
versions: new Map(),
|
||||||
definitions: new Map(),
|
definitions: new Map(),
|
||||||
|
gates: new Map(),
|
||||||
nextStepId: 1,
|
nextStepId: 1,
|
||||||
|
nextGateId: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4 put a schedule-expansion leg in front of the tick. This file is
|
// Phase 4 put a schedule-expansion leg in front of the tick. This file is
|
||||||
@@ -142,9 +146,15 @@ function installStubs() {
|
|||||||
|
|
||||||
runsDb.statusOf = async (id) => store.runs.get(id)?.status || null
|
runsDb.statusOf = async (id) => store.runs.get(id)?.status || null
|
||||||
|
|
||||||
|
// **Escalation only**, which is the statement's own guard rather than a
|
||||||
|
// convenience of this stub: `FIELD(health, 'ok','degraded','stalled') < rank`.
|
||||||
|
// A stub that let health move backwards would make a `stalled` run quietly
|
||||||
|
// become `degraded` again on the next retry, in the tests only.
|
||||||
|
const HEALTH_RANK = { ok: 1, degraded: 2, stalled: 3 }
|
||||||
runsDb.setHealth = async (id, health) => {
|
runsDb.setHealth = async (id, health) => {
|
||||||
const r = store.runs.get(id)
|
const r = store.runs.get(id)
|
||||||
if (!r || r.health === health) return false
|
if (!r || !HEALTH_RANK[health]) return false
|
||||||
|
if (HEALTH_RANK[r.health] >= HEALTH_RANK[health]) return false
|
||||||
r.health = health
|
r.health = health
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -282,6 +292,88 @@ function installStubs() {
|
|||||||
logDb.pruneTerminal = async () => 0
|
logDb.pruneTerminal = async () => 0
|
||||||
|
|
||||||
versionsDb.getById = async (id) => store.versions.get(id) || null
|
versionsDb.getById = async (id) => store.versions.get(id) || null
|
||||||
|
|
||||||
|
// ── The phase gates (Phase 5) ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `open` is INSERT IGNORE against (run_id, phase), and `count`/`satisfy` both
|
||||||
|
// carry `WHERE satisfied_at IS NULL` — the stub reproduces the guards rather
|
||||||
|
// than the convenience, because a gate that could be satisfied twice would
|
||||||
|
// advance a phase twice and no test here would see it.
|
||||||
|
const gateKey = (runId, phase) => `${runId}|${phase}`
|
||||||
|
|
||||||
|
gatesDb.open = async ({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = T0 }) => {
|
||||||
|
const key = gateKey(runId, phase)
|
||||||
|
if (store.gates.has(key)) return false
|
||||||
|
store.gates.set(key, {
|
||||||
|
id: store.nextGateId++,
|
||||||
|
run_id: runId,
|
||||||
|
phase,
|
||||||
|
kind,
|
||||||
|
after_seconds: afterSeconds,
|
||||||
|
trigger_id: triggerId,
|
||||||
|
conditions,
|
||||||
|
needed,
|
||||||
|
tally: 0,
|
||||||
|
entered_at: now,
|
||||||
|
due_at: kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null,
|
||||||
|
last_event: null,
|
||||||
|
last_event_at: null,
|
||||||
|
satisfied_at: null,
|
||||||
|
satisfied_by: null,
|
||||||
|
forced_by: null,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
gatesDb.forPhase = async (runId, phase) => {
|
||||||
|
const g = store.gates.get(gateKey(runId, phase))
|
||||||
|
return g ? { ...g } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
gatesDb.byId = async (id) => {
|
||||||
|
const g = [...store.gates.values()].find((x) => x.id === id)
|
||||||
|
return g ? { ...g } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
gatesDb.listForRun = async (runId) =>
|
||||||
|
[...store.gates.values()].filter((g) => g.run_id === runId).map((g) => ({ ...g }))
|
||||||
|
|
||||||
|
gatesDb.openForTrigger = async (triggerId) =>
|
||||||
|
[...store.gates.values()]
|
||||||
|
.filter((g) => g.trigger_id === triggerId && !g.satisfied_at)
|
||||||
|
.filter((g) => {
|
||||||
|
const r = store.runs.get(g.run_id)
|
||||||
|
return r && ['running', 'paused'].includes(r.status) && r.current_phase === g.phase
|
||||||
|
})
|
||||||
|
.map((g) => ({ ...g }))
|
||||||
|
|
||||||
|
gatesDb.count = async (id, { lastEvent = null, now = T0 } = {}) => {
|
||||||
|
const g = [...store.gates.values()].find((x) => x.id === id)
|
||||||
|
if (!g || g.satisfied_at) return { counted: false, satisfied: false }
|
||||||
|
g.tally += 1
|
||||||
|
g.last_event = lastEvent
|
||||||
|
g.last_event_at = now
|
||||||
|
if (g.tally >= g.needed) {
|
||||||
|
g.satisfied_at = now
|
||||||
|
g.satisfied_by = 'condition'
|
||||||
|
}
|
||||||
|
return { counted: true, satisfied: Boolean(g.satisfied_at), tally: g.tally }
|
||||||
|
}
|
||||||
|
|
||||||
|
gatesDb.noteNearMiss = async (id, { lastEvent = null, now = T0 } = {}) => {
|
||||||
|
const g = [...store.gates.values()].find((x) => x.id === id)
|
||||||
|
if (!g || g.satisfied_at) return false
|
||||||
|
g.last_event = lastEvent
|
||||||
|
g.last_event_at = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
gatesDb.satisfy = async (id, by, { userId = null, now = T0 } = {}) => {
|
||||||
|
const g = [...store.gates.values()].find((x) => x.id === id)
|
||||||
|
if (!g || g.satisfied_at) return false
|
||||||
|
Object.assign(g, { satisfied_at: now, satisfied_by: by, forced_by: userId })
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||||
@@ -323,6 +415,7 @@ const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params,
|
|||||||
const run = (id) => store.runs.get(id)
|
const run = (id) => store.runs.get(id)
|
||||||
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
|
const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
|
||||||
const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind)
|
const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind)
|
||||||
|
const gateOf = (id, phase) => store.gates.get(`${id}|${phase}`)
|
||||||
|
|
||||||
// A registered test action whose behaviour the test dictates.
|
// A registered test action whose behaviour the test dictates.
|
||||||
let scripted
|
let scripted
|
||||||
@@ -746,3 +839,199 @@ test('a resumed run picks up from the step it stopped at', async () => {
|
|||||||
await runner.tick(T0)
|
await runner.tick(T0)
|
||||||
assert.equal(stepsOf(other)[0].status, 'pending', 'a paused run is not swept')
|
assert.equal(stepsOf(other)[0].status, 'pending', 'a paused run is not swept')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// ── Phase 5: advance conditions ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The claim: a phase with a gate waits for it, a phase without one does not
|
||||||
|
// change at all, and NOTHING advances a phase but its condition or a human.
|
||||||
|
|
||||||
|
test('an `after` gate holds a phase whose steps are all done, and releases it on its deadline', async () => {
|
||||||
|
register([scriptedAction('test.a'), scriptedAction('test.b')])
|
||||||
|
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { after: '30m' } },
|
||||||
|
{ key: 'two', label: 'Two', steps: [step('test.b')] },
|
||||||
|
])
|
||||||
|
|
||||||
|
await runner.tick(T0)
|
||||||
|
assert.equal(run(id).status, 'running')
|
||||||
|
assert.equal(run(id).current_phase, 'one', 'the phase did not advance on its steps alone')
|
||||||
|
assert.equal(stepsOf(id)[0].status, 'done', 'but its step ran')
|
||||||
|
assert.equal(scripted['test.b'], undefined, 'and the next phase has not started')
|
||||||
|
|
||||||
|
// A tick a minute later changes nothing: the deadline is computed once, at
|
||||||
|
// entry, and is not re-derived from a `now` that has moved.
|
||||||
|
await runner.tick(later(60_000))
|
||||||
|
assert.equal(run(id).current_phase, 'one')
|
||||||
|
|
||||||
|
await runner.tick(later(30 * 60_000 + 1))
|
||||||
|
assert.equal(run(id).status, 'completed')
|
||||||
|
assert.equal(gateOf(id, 'one').satisfied_by, 'elapsed')
|
||||||
|
const line = store.log.find((l) => l.runId === id && l.kind === 'phase.advanced')
|
||||||
|
assert.equal(line.detail.because, 'elapsed')
|
||||||
|
assert.ok(line.detail.waitedSeconds >= 1800, 'the log says how long it actually waited')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate is in ADDITION to the steps, never instead of them', async () => {
|
||||||
|
// The gate opens immediately; the phase must still not advance, because a
|
||||||
|
// phase whose steps are still running is not finished. The near miss is a
|
||||||
|
// reading under which a boss that spawned early would carry a run past an
|
||||||
|
// announcement that had not been made.
|
||||||
|
register([scriptedAction('test.slow', { perform: async () => ({ ok: true, await: 'human' }) }), scriptedAction('test.b')])
|
||||||
|
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.slow')], advance: { after: '1s' } },
|
||||||
|
{ key: 'two', label: 'Two', steps: [step('test.b')] },
|
||||||
|
])
|
||||||
|
|
||||||
|
await runner.tick(T0)
|
||||||
|
await runner.tick(later(60_000))
|
||||||
|
|
||||||
|
assert.equal(run(id).current_phase, 'one')
|
||||||
|
assert.equal(gateOf(id, 'one').satisfied_at, null, 'the gate was never even consulted')
|
||||||
|
assert.equal(scripted['test.b'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a phase with no gate advances exactly as it did before Phase 5', async () => {
|
||||||
|
register([scriptedAction('test.a'), scriptedAction('test.b')])
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.a')] },
|
||||||
|
{ key: 'two', label: 'Two', steps: [step('test.b')] },
|
||||||
|
])
|
||||||
|
await runner.tick(T0)
|
||||||
|
assert.equal(run(id).status, 'completed')
|
||||||
|
assert.equal(store.gates.size, 0, 'and no gate row was written for it')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `on` gate counts firings from the emit path, and needs all of them', async () => {
|
||||||
|
register([scriptedAction('test.a'), scriptedAction('test.b')])
|
||||||
|
|
||||||
|
const id = seedRun([
|
||||||
|
{
|
||||||
|
key: 'one',
|
||||||
|
label: 'One',
|
||||||
|
steps: [step('test.a')],
|
||||||
|
advance: { on: 'test.trigger', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 },
|
||||||
|
},
|
||||||
|
{ key: 'two', label: 'Two', steps: [step('test.b')] },
|
||||||
|
])
|
||||||
|
|
||||||
|
await runner.tick(T0)
|
||||||
|
assert.equal(run(id).current_phase, 'one')
|
||||||
|
|
||||||
|
const fire = (region) =>
|
||||||
|
gates.observe({ triggerId: 'test.trigger', occurredAt: T0.toISOString(), subject: null, data: { region } })
|
||||||
|
|
||||||
|
// The near miss: it is recorded, it is logged, and it does not count.
|
||||||
|
await fire('Britain')
|
||||||
|
assert.equal(gateOf(id, 'one').tally, 0, 'a firing the condition rejects is not progress')
|
||||||
|
assert.equal(gateOf(id, 'one').last_event.matched, false, 'but it IS recorded — "wrong region" and "nothing happened" are different answers')
|
||||||
|
|
||||||
|
await fire('Yew')
|
||||||
|
assert.equal(gateOf(id, 'one').tally, 1)
|
||||||
|
assert.equal(gateOf(id, 'one').satisfied_at, null, 'one of two is not two')
|
||||||
|
|
||||||
|
await runner.tick(later(1000))
|
||||||
|
assert.equal(run(id).current_phase, 'one', 'and the runner agrees')
|
||||||
|
|
||||||
|
await fire('Yew')
|
||||||
|
assert.equal(gateOf(id, 'one').satisfied_by, 'condition')
|
||||||
|
|
||||||
|
await runner.tick(later(2000))
|
||||||
|
assert.equal(run(id).status, 'completed')
|
||||||
|
|
||||||
|
const evaluated = store.log.filter((l) => l.runId === id && l.kind === 'condition.evaluated')
|
||||||
|
assert.equal(evaluated.length, 3, 'every firing is logged, matched or not')
|
||||||
|
assert.deepEqual(evaluated.map((l) => l.detail.matched), [false, true, true])
|
||||||
|
assert.deepEqual(evaluated.map((l) => l.detail.seen), [0, 1, 2], 'and the tally logged is the one the row holds')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a phase forced by a human is not logged as advanced twice', async () => {
|
||||||
|
// `phase.advanced` is written by whoever made the DECISION. The advance
|
||||||
|
// control writes it with the actor and the reason; a second line from the
|
||||||
|
// tick that then acts on the satisfied gate made the console show the phase
|
||||||
|
// advancing twice, the less informative one last. Found in the live walk.
|
||||||
|
register([scriptedAction('test.a')])
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { on: 'test.trigger', count: 1 } },
|
||||||
|
{ key: 'two', label: 'Two', steps: [] },
|
||||||
|
])
|
||||||
|
await runner.tick(T0)
|
||||||
|
|
||||||
|
// What the control does: satisfy the gate as `forced` and stop.
|
||||||
|
await gatesDb.satisfy(gateOf(id, 'one').id, 'forced', { userId: 7, now: later(1000) })
|
||||||
|
await runner.tick(later(2000))
|
||||||
|
|
||||||
|
const advanced = store.log.filter((l) => l.runId === id && l.kind === 'phase.advanced')
|
||||||
|
assert.equal(advanced.length, 0, 'the tick writes no line of its own for a decision it did not make')
|
||||||
|
assert.equal(run(id).current_phase, 'two', 'and it still advances the phase')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a firing that arrives after the gate closed does not keep counting', async () => {
|
||||||
|
register([scriptedAction('test.a')])
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { on: 'test.trigger', count: 1 } },
|
||||||
|
{ key: 'two', label: 'Two', steps: [] },
|
||||||
|
])
|
||||||
|
await runner.tick(T0)
|
||||||
|
await gates.observe({ triggerId: 'test.trigger', occurredAt: T0.toISOString(), data: {} })
|
||||||
|
assert.equal(gateOf(id, 'one').tally, 1)
|
||||||
|
|
||||||
|
await gates.observe({ triggerId: 'test.trigger', occurredAt: T0.toISOString(), data: {} })
|
||||||
|
assert.equal(gateOf(id, 'one').tally, 1, 'the guard is `WHERE satisfied_at IS NULL`, not a read-then-write')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `on` gate that waits past the threshold marks the run stalled, once', async () => {
|
||||||
|
register([scriptedAction('test.a')])
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { on: 'test.trigger', count: 1 } },
|
||||||
|
{ key: 'two', label: 'Two', steps: [] },
|
||||||
|
])
|
||||||
|
|
||||||
|
await runner.tick(T0)
|
||||||
|
assert.equal(run(id).health, 'ok', 'a phase that has just begun waiting is not stalled')
|
||||||
|
|
||||||
|
await runner.tick(later(gates.STALL_MS + 1000))
|
||||||
|
assert.equal(run(id).health, 'stalled')
|
||||||
|
|
||||||
|
await runner.tick(later(gates.STALL_MS + 60_000))
|
||||||
|
const health = store.log.filter((l) => l.runId === id && l.kind === 'run.health')
|
||||||
|
assert.equal(health.length, 1, 'and it is logged once, not once per tick')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `after` gate is never stalled, however long it was authored to wait', async () => {
|
||||||
|
register([scriptedAction('test.a')])
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { after: '2d' } },
|
||||||
|
{ key: 'two', label: 'Two', steps: [] },
|
||||||
|
])
|
||||||
|
await runner.tick(T0)
|
||||||
|
await runner.tick(later(gates.STALL_MS * 3))
|
||||||
|
assert.equal(run(id).health, 'ok', 'a phase waiting out the delay it was given is working, not stalled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('health only ever escalates, so a retry after a stall does not demote it', async () => {
|
||||||
|
assert.equal(await runsDb.setHealth(seedRun([{ key: 'main', label: 'Main', steps: [] }]), 'degraded'), true)
|
||||||
|
const id = seedRun([{ key: 'main', label: 'Main', steps: [] }])
|
||||||
|
assert.equal(await runsDb.setHealth(id, 'stalled'), true)
|
||||||
|
assert.equal(await runsDb.setHealth(id, 'degraded'), false, 'a later degradation cannot undo a stall')
|
||||||
|
assert.equal(run(id).health, 'stalled')
|
||||||
|
assert.equal(await runsDb.setHealth(id, 'ok'), false, 'and nothing returns a run to healthy')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('re-entering a phase does not open a second gate', async () => {
|
||||||
|
// The INSERT IGNORE that makes recovery from a died-mid-entry process
|
||||||
|
// uneventful — the same property `materialisePhase` has.
|
||||||
|
register([scriptedAction('test.a')])
|
||||||
|
const id = seedRun([
|
||||||
|
{ key: 'one', label: 'One', steps: [step('test.a')], advance: { after: '1h' } },
|
||||||
|
{ key: 'two', label: 'Two', steps: [] },
|
||||||
|
])
|
||||||
|
await runner.tick(T0)
|
||||||
|
const entered = gateOf(id, 'one').entered_at
|
||||||
|
await runner.tick(later(60_000))
|
||||||
|
await runner.tick(later(120_000))
|
||||||
|
assert.equal(store.gates.size, 1)
|
||||||
|
assert.equal(gateOf(id, 'one').entered_at, entered, 'and the deadline it was given does not move')
|
||||||
|
})
|
||||||
|
|||||||
@@ -54,6 +54,32 @@
|
|||||||
// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no
|
// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no
|
||||||
// series.
|
// series.
|
||||||
//
|
//
|
||||||
|
// **Phase 5 added the gate statements**, and the increment is the one on this
|
||||||
|
// list with the closest precedent: engagement's cooldown claim was green against
|
||||||
|
// its stub and always allowed the send against a real server, because the
|
||||||
|
// connector defaults `foundRows: true`. A gate's tally is the same shape of
|
||||||
|
// claim — a conditional UPDATE whose answer decides something — so it is proved
|
||||||
|
// here rather than believed.
|
||||||
|
//
|
||||||
|
// * **`GATE_COUNT`** - `tally = tally + 1` with `CASE WHEN tally + 1 >=
|
||||||
|
// needed` inside the same statement, guarded on `satisfied_at IS NULL`. Two
|
||||||
|
// emits arriving together must each add one and exactly ONE of them must
|
||||||
|
// cross the threshold; a read-then-write would let both see 2 of 3. **This
|
||||||
|
// is the statement that failed here first**: MariaDB evaluates SET
|
||||||
|
// assignments left to right with the values already assigned, so an
|
||||||
|
// increment written before the CASE made the CASE read the new tally and
|
||||||
|
// close a two-firing gate on the first firing. The ORDER of the SET list is
|
||||||
|
// load-bearing, and only a real server says so.
|
||||||
|
// * **`GATE_SATISFY`** - the same guard for the two reasons that are not a
|
||||||
|
// firing: an `after` deadline passing, and a human forcing it. A force that
|
||||||
|
// races the tick must lose harmlessly.
|
||||||
|
// * **`GATE_OPEN_FOR_TRIGGER`** - the emit path's only query, and the one
|
||||||
|
// index in this feature on a hot path. Its JOIN is what stops a gate
|
||||||
|
// belonging to a cancelled run counting for ever.
|
||||||
|
// * **`uq_evgate_phase`** - what makes `open` an INSERT IGNORE, so a process
|
||||||
|
// that died between entering a phase and opening its gate opens no second
|
||||||
|
// one on the next tick.
|
||||||
|
//
|
||||||
// Plus the two unique indexes that are load-bearing rather than tidy:
|
// Plus the two unique indexes that are load-bearing rather than tidy:
|
||||||
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
|
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
|
||||||
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
|
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
|
||||||
@@ -147,6 +173,29 @@ CREATE TABLE event_run_steps (
|
|||||||
UNIQUE KEY uq_evstep_slot (run_id, phase, seq),
|
UNIQUE KEY uq_evstep_slot (run_id, phase, seq),
|
||||||
INDEX idx_evstep_due (status, due_at)
|
INDEX idx_evstep_due (status, due_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
CREATE TABLE event_run_phase_gates (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
run_id BIGINT NOT NULL,
|
||||||
|
phase VARCHAR(64) NOT NULL,
|
||||||
|
kind ENUM('after','on') NOT NULL,
|
||||||
|
after_seconds INT NULL,
|
||||||
|
trigger_id VARCHAR(96) NULL,
|
||||||
|
conditions JSON NULL,
|
||||||
|
needed INT NOT NULL DEFAULT 1,
|
||||||
|
tally INT NOT NULL DEFAULT 0,
|
||||||
|
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
due_at DATETIME NULL,
|
||||||
|
last_event JSON NULL,
|
||||||
|
last_event_at DATETIME NULL,
|
||||||
|
satisfied_at DATETIME NULL,
|
||||||
|
satisfied_by VARCHAR(16) NULL,
|
||||||
|
forced_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||||
|
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
`
|
`
|
||||||
|
|
||||||
// The statements under test, verbatim from `eventRuns.db.js` and
|
// The statements under test, verbatim from `eventRuns.db.js` and
|
||||||
@@ -228,6 +277,34 @@ const LAST_STARTED_SEQ = `
|
|||||||
SELECT MAX(seq) AS seq FROM event_run_steps
|
SELECT MAX(seq) AS seq FROM event_run_steps
|
||||||
WHERE run_id = ? AND phase = ? AND status <> 'pending'`
|
WHERE run_id = ? AND phase = ? AND status <> 'pending'`
|
||||||
|
|
||||||
|
// Phase 5's four, verbatim from `eventPhaseGates.db.js`.
|
||||||
|
const GATE_OPEN = `
|
||||||
|
INSERT IGNORE INTO event_run_phase_gates
|
||||||
|
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
|
const GATE_COUNT = `
|
||||||
|
UPDATE event_run_phase_gates
|
||||||
|
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
|
||||||
|
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
|
||||||
|
last_event = ?,
|
||||||
|
last_event_at = ?,
|
||||||
|
tally = tally + 1
|
||||||
|
WHERE id = ? AND satisfied_at IS NULL`
|
||||||
|
|
||||||
|
const GATE_SATISFY = `
|
||||||
|
UPDATE event_run_phase_gates
|
||||||
|
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
|
||||||
|
WHERE id = ? AND satisfied_at IS NULL`
|
||||||
|
|
||||||
|
const GATE_OPEN_FOR_TRIGGER = `
|
||||||
|
SELECT g.* FROM event_run_phase_gates g
|
||||||
|
JOIN event_runs r ON r.id = g.run_id
|
||||||
|
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
|
||||||
|
AND r.status IN ('running','paused')
|
||||||
|
AND r.current_phase = g.phase
|
||||||
|
LIMIT 200`
|
||||||
|
|
||||||
const MATERIALISE_RUN = `
|
const MATERIALISE_RUN = `
|
||||||
INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
|
INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
@@ -341,6 +418,7 @@ const rows = (r) => Number(r.affectedRows)
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
if (!available) return
|
if (!available) return
|
||||||
|
await pool.query('DELETE FROM event_run_phase_gates')
|
||||||
await pool.query('DELETE FROM event_run_steps')
|
await pool.query('DELETE FROM event_run_steps')
|
||||||
await pool.query('DELETE FROM event_runs')
|
await pool.query('DELETE FROM event_runs')
|
||||||
await pool.query('DELETE FROM event_definitions')
|
await pool.query('DELETE FROM event_definitions')
|
||||||
@@ -891,3 +969,131 @@ test('a scheduled run whose started_at is somehow set is left alone', async (t)
|
|||||||
const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0]
|
const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0]
|
||||||
assert.equal(Number(after.version_id), 3)
|
assert.equal(Number(after.version_id), 3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// ── Phase 5: the gate statements ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A run in `running` on one phase, plus an open gate for it. */
|
||||||
|
async function seedGate({ kind = 'on', needed = 1, trigger = 'uo.champ.boss_up', status = 'running', phase = 'boss' } = {}) {
|
||||||
|
const { runId } = await seedRun({ status })
|
||||||
|
await pool.query('UPDATE event_runs SET current_phase = ? WHERE id = ?', [phase, runId])
|
||||||
|
const g = await pool.query(GATE_OPEN, [
|
||||||
|
runId,
|
||||||
|
phase,
|
||||||
|
kind,
|
||||||
|
kind === 'after' ? 1800 : null,
|
||||||
|
kind === 'on' ? trigger : null,
|
||||||
|
kind === 'on' ? JSON.stringify({ variable: 'region', cmp: 'eq', value: 'Yew' }) : null,
|
||||||
|
needed,
|
||||||
|
T0,
|
||||||
|
kind === 'after' ? later(1800_000) : null,
|
||||||
|
])
|
||||||
|
return { runId, gateId: Number(g.insertId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const gateById = async (id) => (await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [id]))[0]
|
||||||
|
|
||||||
|
test('two firings arriving together each count, and exactly one crosses the threshold', async (t) => {
|
||||||
|
if (needDb(t)) return
|
||||||
|
const { gateId } = await seedGate({ needed: 2 })
|
||||||
|
|
||||||
|
// The whole reason the CASE is inside the UPDATE: a read-then-write would let
|
||||||
|
// both of these see "1 of 2" and neither satisfy, or both satisfy and advance
|
||||||
|
// one phase twice.
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
pool.query(GATE_COUNT, [T0, null, T0, gateId]),
|
||||||
|
pool.query(GATE_COUNT, [T0, null, T0, gateId]),
|
||||||
|
])
|
||||||
|
assert.equal(rows(a) + rows(b), 2, 'both increments land')
|
||||||
|
|
||||||
|
const gate = await gateById(gateId)
|
||||||
|
assert.equal(Number(gate.tally), 2)
|
||||||
|
assert.ok(gate.satisfied_at, 'and the second one closed it')
|
||||||
|
assert.equal(gate.satisfied_by, 'condition')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the threshold is read BEFORE the increment, not after it', async (t) => {
|
||||||
|
if (needDb(t)) return
|
||||||
|
// The defect this file was written to catch, and did: MariaDB evaluates SET
|
||||||
|
// assignments left to right with the values already assigned, so an increment
|
||||||
|
// placed FIRST makes the CASE after it read `new + 1 >= needed` and close a
|
||||||
|
// two-firing gate on the first firing. Every stub of this agrees with the
|
||||||
|
// intent rather than with the server, which is exactly why it survived one.
|
||||||
|
const { gateId } = await seedGate({ needed: 2 })
|
||||||
|
await pool.query(GATE_COUNT, [T0, null, T0, gateId])
|
||||||
|
const half = await gateById(gateId)
|
||||||
|
assert.equal(Number(half.tally), 1)
|
||||||
|
assert.equal(half.satisfied_at, null, 'one of two is not two')
|
||||||
|
|
||||||
|
await pool.query(GATE_COUNT, [T0, null, T0, gateId])
|
||||||
|
assert.ok((await gateById(gateId)).satisfied_at, 'and the second one is')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an increment against a closed gate is refused, not applied', async (t) => {
|
||||||
|
if (needDb(t)) return
|
||||||
|
const { gateId } = await seedGate({ needed: 1 })
|
||||||
|
assert.equal(rows(await pool.query(GATE_COUNT, [T0, null, T0, gateId])), 1)
|
||||||
|
|
||||||
|
// `WHERE satisfied_at IS NULL` — and 0 rather than 1, which is the whole
|
||||||
|
// reason this file exists: `foundRows: true` would answer 1 for a no-op.
|
||||||
|
assert.equal(rows(await pool.query(GATE_COUNT, [T0, null, T0, gateId])), 0)
|
||||||
|
assert.equal(Number((await gateById(gateId)).tally), 1, 'the tally does not keep climbing after the phase moved on')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a force and a deadline race the same guard, and only one of them wins', async (t) => {
|
||||||
|
if (needDb(t)) return
|
||||||
|
const { gateId } = await seedGate({ kind: 'after' })
|
||||||
|
const [forced, elapsed] = await Promise.all([
|
||||||
|
pool.query(GATE_SATISFY, [T0, 'forced', 7, gateId]),
|
||||||
|
pool.query(GATE_SATISFY, [T0, 'elapsed', null, gateId]),
|
||||||
|
])
|
||||||
|
assert.equal(rows(forced) + rows(elapsed), 1, 'exactly one of the two writes')
|
||||||
|
|
||||||
|
const gate = await gateById(gateId)
|
||||||
|
assert.ok(['forced', 'elapsed'].includes(gate.satisfied_by))
|
||||||
|
// Whichever won is the one in the row, and the log records that one — never
|
||||||
|
// both.
|
||||||
|
assert.equal(gate.satisfied_by === 'forced' ? Number(gate.forced_by) : gate.forced_by, gate.satisfied_by === 'forced' ? 7 : null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opening a gate twice writes one row', async (t) => {
|
||||||
|
if (needDb(t)) return
|
||||||
|
const { runId } = await seedGate()
|
||||||
|
// The INSERT IGNORE that makes recovery from a process which died between
|
||||||
|
// entering a phase and opening its gate uneventful.
|
||||||
|
const again = await pool.query(GATE_OPEN, [runId, 'boss', 'on', null, 'uo.champ.boss_up', null, 1, later(60_000), null])
|
||||||
|
assert.equal(rows(again), 0)
|
||||||
|
const all = await pool.query('SELECT * FROM event_run_phase_gates WHERE run_id = ?', [runId])
|
||||||
|
assert.equal(all.length, 1)
|
||||||
|
assert.equal(new Date(all[0].entered_at).getTime(), T0.getTime(), 'and the first entry time is the one that stands')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the emit path sees only gates whose run is live and on that phase', async (t) => {
|
||||||
|
if (needDb(t)) return
|
||||||
|
const live = await seedGate()
|
||||||
|
const paused = await seedGate({ status: 'paused' })
|
||||||
|
const cancelled = await seedGate({ status: 'scheduled' })
|
||||||
|
await pool.query('UPDATE event_runs SET status = ? WHERE id = ?', ['cancelled', cancelled.runId])
|
||||||
|
|
||||||
|
const moved = await seedGate()
|
||||||
|
await pool.query('UPDATE event_runs SET current_phase = ? WHERE id = ?', ['cleanup', moved.runId])
|
||||||
|
|
||||||
|
const closed = await seedGate()
|
||||||
|
await pool.query(GATE_SATISFY, [T0, 'forced', null, closed.gateId])
|
||||||
|
|
||||||
|
const found = (await pool.query(GATE_OPEN_FOR_TRIGGER, ['uo.champ.boss_up'])).map((g) => Number(g.id))
|
||||||
|
assert.deepEqual(found.sort((a, b) => a - b), [live.gateId, paused.gateId].sort((a, b) => a - b))
|
||||||
|
// `paused` counts: the world does not stop because an operator paused the
|
||||||
|
// console, and discarding firings during a pause would make pause destructive.
|
||||||
|
assert.ok(found.includes(paused.gateId))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate goes with its run', async (t) => {
|
||||||
|
if (needDb(t)) return
|
||||||
|
// ON DELETE CASCADE, which is what keeps an orphaned gate from outliving the
|
||||||
|
// run whose phase it was about.
|
||||||
|
const { runId, gateId } = await seedGate()
|
||||||
|
await pool.query('DELETE FROM event_run_steps WHERE run_id = ?', [runId])
|
||||||
|
await pool.query('DELETE FROM event_runs WHERE id = ?', [runId])
|
||||||
|
assert.equal((await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [gateId])).length, 0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
// • a dormant step blocks a PUBLISH and never a SAVE
|
// • a dormant step blocks a PUBLISH and never a SAVE
|
||||||
// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)`
|
// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)`
|
||||||
// would silently collapse them into one at materialisation
|
// would silently collapse them into one at materialisation
|
||||||
// • a key a later phase owns (`advance`, `announcements`) is REFUSED rather
|
// • a key a later phase owns (`announcements`) is REFUSED rather than
|
||||||
// than preserved, so no corpus of unvalidated specs accumulates
|
// preserved, so no corpus of unvalidated specs accumulates
|
||||||
|
// • a phase's `advance` gate (Phase 5) is checked at SAVE against the
|
||||||
|
// trigger's declaration with the offending variable named, and a gate on an
|
||||||
|
// unregistered trigger is dormant on exactly the rule a step's action is
|
||||||
process.env.DB_HOST = '127.0.0.1'
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
process.env.DB_PORT = '59999'
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
@@ -184,12 +187,166 @@ test('a key a later phase owns is refused, not silently preserved', () => {
|
|||||||
assert.equal(top.ok, false)
|
assert.equal(top.ok, false)
|
||||||
assert.match(top.errors.join('\n'), /unknown key "announcements"/)
|
assert.match(top.errors.join('\n'), /unknown key "announcements"/)
|
||||||
|
|
||||||
|
// `advance` was one of these until Phase 5 gave it a meaning; the refusal
|
||||||
|
// moved down a level rather than going away, and a key inside a gate that no
|
||||||
|
// shape declares is refused on the same argument.
|
||||||
const phase = spec.validate({
|
const phase = spec.validate({
|
||||||
schedule: { kind: 'manual' },
|
schedule: { kind: 'manual' },
|
||||||
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m' } }],
|
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m', timeout: '2h' } }],
|
||||||
})
|
})
|
||||||
assert.equal(phase.ok, false)
|
assert.equal(phase.ok, false)
|
||||||
assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/)
|
assert.match(phase.errors.join('\n'), /unknown key\(s\) timeout for an "after" gate/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -- The advance gate (Phase 5) --------------------------------------------
|
||||||
|
//
|
||||||
|
// The phase's stated trap: a condition is checked at SAVE against the trigger's
|
||||||
|
// declaration, with the offending variable NAMED. A predicate that silently
|
||||||
|
// reads `undefined` is a phase that silently never advances, and the night you
|
||||||
|
// find out is the night of the event.
|
||||||
|
|
||||||
|
test('an `after` gate normalises its duration the way `days` is normalised', () => {
|
||||||
|
const ok = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '120m' } }],
|
||||||
|
})
|
||||||
|
assert.equal(ok.ok, true)
|
||||||
|
assert.deepEqual(ok.spec.phases[0].advance, { after: '2h' }, 'two spellings of one delay are one spec')
|
||||||
|
assert.equal(spec.parseAfter('2h'), 7200)
|
||||||
|
assert.equal(spec.formatAfter(5400), '90m', 'and a duration with no whole larger unit keeps the smaller one')
|
||||||
|
|
||||||
|
for (const bad of ['0s', '30', '1h30m', 'soon', '0.5h', `${31 * 86_400}s`]) {
|
||||||
|
const result = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: bad } }],
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, false, `"${bad}" should not validate`)
|
||||||
|
assert.match(result.errors.join('\n'), /expected a duration like "30m"/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an `on` gate is validated against the trigger declaration, and names the variable', () => {
|
||||||
|
const bad = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
key: 'main',
|
||||||
|
label: 'Main',
|
||||||
|
steps: [],
|
||||||
|
advance: { on: 'news.post', where: { variable: 'reigon', cmp: 'eq', value: 'Yew' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(bad.ok, false)
|
||||||
|
assert.match(bad.errors.join('\n'), /"reigon" is not a variable of "news.post"/)
|
||||||
|
assert.match(bad.errors.join('\n'), /spec\.phases\[0\]\.advance\.where/, 'and it says which phase')
|
||||||
|
|
||||||
|
// The type check is the grammar's, unchanged: `gt` on a string is refused at
|
||||||
|
// save rather than quietly answering false for ever.
|
||||||
|
const typed = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [
|
||||||
|
{ key: 'main', label: 'Main', steps: [], advance: { on: 'news.post', where: { variable: 'title', cmp: 'gt', value: 'x' } } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(typed.ok, false)
|
||||||
|
assert.match(typed.errors.join('\n'), /"gt" cannot be applied to a string/)
|
||||||
|
|
||||||
|
const ok = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
key: 'main',
|
||||||
|
label: 'Main',
|
||||||
|
steps: [],
|
||||||
|
advance: { on: 'news.post', where: { variable: 'category', cmp: 'eq', value: 'Five on Friday' }, count: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(ok.ok, true)
|
||||||
|
assert.deepEqual(ok.spec.phases[0].advance, {
|
||||||
|
on: 'news.post',
|
||||||
|
where: { variable: 'category', cmp: 'eq', value: 'Five on Friday' },
|
||||||
|
count: 3,
|
||||||
|
dormant: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate on an unregistered trigger is dormant: it saves, and it will not publish', () => {
|
||||||
|
const result = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [
|
||||||
|
{ key: 'main', label: 'Main', steps: [], advance: { on: 'gone.trigger', where: { variable: 'x', cmp: 'eq', value: 1 } } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, true, 'uninstalling a module must not be destructive to an author’s work')
|
||||||
|
assert.equal(result.spec.phases[0].advance.dormant, true)
|
||||||
|
assert.deepEqual(
|
||||||
|
result.spec.phases[0].advance.where,
|
||||||
|
{ variable: 'x', cmp: 'eq', value: 1 },
|
||||||
|
'and the predicate is kept, not deleted for want of a declaration to check it against',
|
||||||
|
)
|
||||||
|
|
||||||
|
const pub = spec.publishable(result.spec)
|
||||||
|
assert.equal(pub.ok, false)
|
||||||
|
assert.deepEqual(pub.dormant, ['gone.trigger'], 'the same list a dormant ACTION lands in, and the same message')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate names exactly one shape, and its count is bounded', () => {
|
||||||
|
const both = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '1h', on: 'news.post' } }],
|
||||||
|
})
|
||||||
|
assert.equal(both.ok, false)
|
||||||
|
assert.match(both.errors.join('\n'), /exactly one of "after" or "on"/)
|
||||||
|
|
||||||
|
const neither = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [{ key: 'main', label: 'Main', steps: [], advance: {} }],
|
||||||
|
})
|
||||||
|
assert.equal(neither.ok, false)
|
||||||
|
|
||||||
|
for (const count of [0, -1, 1.5, spec.MAX_ADVANCE_COUNT + 1]) {
|
||||||
|
const result = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [{ key: 'main', label: 'Main', steps: [], advance: { on: 'news.post', count } }],
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, false, `count ${count} should not validate`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validate accepts its own output — the walk found this one', () => {
|
||||||
|
// A saved spec is re-validated on every later save and AGAIN AT PUBLISH.
|
||||||
|
// `validate` normalises a gate with `dormant`, and the first draft refused
|
||||||
|
// that key on the way back in: the definition saved, and then publishing it
|
||||||
|
// answered 400 over a field the validator itself had written. The rule was
|
||||||
|
// already on the page for a step's `actionVersion` and `dormant`; the gate
|
||||||
|
// just had to follow it. `validate(validate(x)) === validate(x)`.
|
||||||
|
const once = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [
|
||||||
|
{ key: 'a', label: 'A', steps: [], advance: { on: 'news.post', where: { variable: 'title', cmp: 'present' }, count: 2 } },
|
||||||
|
{ key: 'b', label: 'B', steps: [], advance: { after: '15m' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(once.ok, true)
|
||||||
|
const twice = spec.validate(once.spec)
|
||||||
|
assert.equal(twice.ok, true, twice.errors?.join('; '))
|
||||||
|
assert.deepEqual(twice.spec, once.spec)
|
||||||
|
|
||||||
|
// And `dormant` is RECOMPUTED, never trusted: a spec claiming a dead trigger
|
||||||
|
// is fine must not publish just because it says so.
|
||||||
|
const lying = spec.validate({
|
||||||
|
schedule: { kind: 'manual' },
|
||||||
|
phases: [{ key: 'a', label: 'A', steps: [], advance: { on: 'gone.trigger', count: 1, dormant: false } }],
|
||||||
|
})
|
||||||
|
assert.equal(lying.spec.phases[0].advance.dormant, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a phase with no gate carries no `advance` key at all', () => {
|
||||||
|
const result = spec.validate({ schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal('advance' in result.spec.phases[0], false, 'so one phase gaining a gate is not a diff on every phase')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── The schedule shapes (Phase 4) ────────────────────────────────────
|
// ── The schedule shapes (Phase 4) ────────────────────────────────────
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const runsDb = require('../src/model/events/eventRuns.db')
|
|||||||
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||||
const logDb = require('../src/model/events/eventRunLog.db')
|
const logDb = require('../src/model/events/eventRunLog.db')
|
||||||
const seriesDb = require('../src/model/events/eventSeries.db')
|
const seriesDb = require('../src/model/events/eventSeries.db')
|
||||||
|
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||||
const activity = require('../src/model/activity/activity.model')
|
const activity = require('../src/model/activity/activity.model')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ for (const [name, mod] of [
|
|||||||
['stepsDb', stepsDb],
|
['stepsDb', stepsDb],
|
||||||
['logDb', logDb],
|
['logDb', logDb],
|
||||||
['seriesDb', seriesDb],
|
['seriesDb', seriesDb],
|
||||||
|
['gatesDb', gatesDb],
|
||||||
['activity', activity],
|
['activity', activity],
|
||||||
]) {
|
]) {
|
||||||
originals[name] = { mod, fns: { ...mod } }
|
originals[name] = { mod, fns: { ...mod } }
|
||||||
@@ -76,6 +78,7 @@ function installStubs() {
|
|||||||
steps: new Map(),
|
steps: new Map(),
|
||||||
log: [],
|
log: [],
|
||||||
series: new Map(),
|
series: new Map(),
|
||||||
|
gates: [],
|
||||||
occurrences: new Set(),
|
occurrences: new Set(),
|
||||||
nextDefinition: 1,
|
nextDefinition: 1,
|
||||||
nextVersion: 1,
|
nextVersion: 1,
|
||||||
@@ -250,6 +253,15 @@ function installStubs() {
|
|||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5 gave `runs.detail()` a third leg, and an unstubbed one is a real
|
||||||
|
// query against the dead port this file points at: the run-console test hung
|
||||||
|
// for ten seconds and then failed with ECONNREFUSED, saying nothing whatever
|
||||||
|
// about the route. **This is the third time a new leg has caught a stubbing
|
||||||
|
// file out** — Phase 4's expansion leg did it to `eventRunner.test.js`, where
|
||||||
|
// it merely made the file slow. Worth the comment: when the runner or a model
|
||||||
|
// gains a leg, every file that stubs the layer under it needs the stub.
|
||||||
|
gatesDb.listForRun = async (runId) => store.gates.filter((g) => g.run_id === runId)
|
||||||
|
|
||||||
logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
|
logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
|
||||||
logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {
|
logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {
|
||||||
store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() })
|
store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() })
|
||||||
|
|||||||
Reference in New Issue
Block a user