The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
765 lines
32 KiB
JavaScript
765 lines
32 KiB
JavaScript
// ── What the three Events screens say, and what they let staff press ───────
|
||
//
|
||
// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server
|
||
// decides what may be saved, and the six control statements decide what may
|
||
// happen to a run — every one of them is a compare-and-set that re-checks the
|
||
// status this file only *predicted*. What is here is the part that would be
|
||
// wrong silently: a form that drops an authored step, a params box that posts a
|
||
// string where the action declared an int, and above all a console that offers a
|
||
// button the server is going to refuse.
|
||
//
|
||
// **The controls are modelled here rather than inline in the console for one
|
||
// reason: they can be tested against the server's rules.** A button that 409s is
|
||
// not a bug the way a wrong write is, but it is the failure mode an operator
|
||
// meets at 2am while the thing they are trying to stop keeps running — so the
|
||
// guards are written twice on purpose and the copy is checked.
|
||
|
||
// **The condition builder is borrowed, not rebuilt.** §I says the step editor
|
||
// reuses "the condition builder, exactly" — and a phase's advance gate is
|
||
// literally the engagement grammar, validated on the server by
|
||
// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
|
||
// from becoming a second opinion about a grammar core owns.
|
||
import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
|
||
|
||
// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
|
||
export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
|
||
|
||
export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status)
|
||
|
||
/** A step waiting on a human: `running`, with nothing holding it. */
|
||
export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked)
|
||
|
||
/**
|
||
* The highest `seq` of a step in this phase that is not still `pending` — the
|
||
* furthest the phase has got — or null when none of it has been attempted.
|
||
*
|
||
* The same rule as the server's `lastStartedSeq`, over the step list the console
|
||
* already has, and used only to decide whether to OFFER retry. The near miss is
|
||
* worth keeping in view: "the lowest step that is not finished" looks like the
|
||
* same thing and is not, because the runner steps OVER a failed step. Under that
|
||
* rule a phase that carried on past an `on_failure: skip` failure and then paused
|
||
* at a later one would offer retry on the wrong step.
|
||
*/
|
||
export function lastStartedSeqOf(steps, phase) {
|
||
const started = (steps || [])
|
||
.filter((s) => s.phase === phase && s.status !== 'pending')
|
||
.map((s) => Number(s.seq))
|
||
return started.length ? Math.max(...started) : null
|
||
}
|
||
|
||
/**
|
||
* Which run-level controls to offer.
|
||
*
|
||
* `pause` is `starting`/`running` only: a `scheduled` occurrence that should not
|
||
* 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
|
||
* 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, gates = [], steps = []) {
|
||
if (!run) return { pause: false, resume: false, cancel: false, advance: false }
|
||
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 {
|
||
pause: ['starting', 'running'].includes(run.status),
|
||
resume: run.status === 'paused',
|
||
cancel: !terminal,
|
||
advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Which step-level controls to offer, for one step of one run.
|
||
*
|
||
* `retry` carries the guard worth restating: only while the run is PAUSED, only
|
||
* on a `failed` step of the phase the run is currently in, and only when that
|
||
* step is the furthest one the phase has reached. A failed step under an
|
||
* `on_failure` of `skip` is one the run has already moved past, and re-queueing
|
||
* it would put a pending row behind the runner's cursor, where it would sit for
|
||
* ever.
|
||
*/
|
||
export function stepControlsFor(run, step, steps) {
|
||
const none = { confirm: false, skip: false, retry: false }
|
||
if (!run || !step) return none
|
||
if (isTerminalRun(run.status)) return none
|
||
|
||
const parked = isParked(step)
|
||
const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null
|
||
|
||
return {
|
||
confirm: parked,
|
||
skip: parked || step.status === 'pending',
|
||
retry:
|
||
run.status === 'paused' &&
|
||
step.status === 'failed' &&
|
||
step.phase === run.currentPhase &&
|
||
furthest !== null &&
|
||
Number(furthest) === Number(step.seq),
|
||
}
|
||
}
|
||
|
||
// ── The definition form ────────────────────────────────────────────────────
|
||
|
||
export const BLANK_PHASE_KEY = 'phase'
|
||
|
||
const nextPhaseKey = (phases) => {
|
||
const used = new Set((phases || []).map((p) => p.key))
|
||
for (let n = 1; n < 100; n++) {
|
||
const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}`
|
||
if (!used.has(key)) return key
|
||
}
|
||
return `${BLANK_PHASE_KEY}-${Date.now()}`
|
||
}
|
||
|
||
/**
|
||
* A new step, with its params PREFILLED from the action's declared examples.
|
||
*
|
||
* Every param carries a required `example` — that requirement is the reason this
|
||
* works — so a fresh `core.announce` step arrives with the right keys and
|
||
* plausible values rather than empty. Phase 13 turned the box into a form and
|
||
* this stayed exactly as it was: a form whose fields start at the declared
|
||
* example is a step an author edits rather than one they compose.
|
||
*/
|
||
export function blankStep(action) {
|
||
const params = {}
|
||
for (const p of action?.params || []) {
|
||
if (p.required || p.example !== undefined) params[p.name] = p.example
|
||
}
|
||
return {
|
||
actionId: action?.id || '',
|
||
label: action?.label || '',
|
||
onFailure: '',
|
||
paramsText: JSON.stringify(params, null, 2),
|
||
}
|
||
}
|
||
|
||
export function blankPhase(phases) {
|
||
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, ...blankWhere() }
|
||
}
|
||
|
||
/**
|
||
* The `where` predicate as the BUILDER holds it (Phase 13).
|
||
*
|
||
* `whereText` survives beside the rows and is not vestigial: it is what a
|
||
* predicate the builder cannot render is shown as, and what is posted for one.
|
||
* See `whereFormFrom`.
|
||
*/
|
||
export function blankWhere() {
|
||
return { whereOp: 'and', whereRows: [], whereEditable: true, 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 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,
|
||
...whereFormFrom(advance.where),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A stored `where` tree → the builder's flat rows (Phase 13).
|
||
*
|
||
* **This is `conditionRowsFrom` and it is deliberately the same function**, not a
|
||
* second one shaped like it. The grammar behind a phase gate is the engagement
|
||
* condition grammar — the server validates it with `engagement/conditions.js`
|
||
* and renders the diagnosis panel's sentence with the same labels — so an editor
|
||
* here that re-decided what a tree looks like would be the second implementation
|
||
* §I refuses on the read side for exactly this reason.
|
||
*
|
||
* A tree the flat editor cannot hold (`A and (B or C)`) comes back
|
||
* `whereEditable: false` and is SHOWN as its JSON rather than silently
|
||
* flattened: `A and B and C` fires on different events, and an author would have
|
||
* no way to know the save had done it to them.
|
||
*/
|
||
export function whereFormFrom(where) {
|
||
const blank = blankWhere()
|
||
if (!where) return blank
|
||
const rows = conditionRowsFrom(where)
|
||
return {
|
||
whereOp: rows.op,
|
||
whereRows: rows.rows,
|
||
whereEditable: rows.editable,
|
||
whereText: JSON.stringify(where, null, 2),
|
||
}
|
||
}
|
||
|
||
/** The editor's working state, from what `GET /admin/events/:id` returned. */
|
||
export function formFromDefinition(event) {
|
||
const spec = event?.spec || {}
|
||
return {
|
||
title: event?.title || '',
|
||
summary: event?.summary || '',
|
||
body: event?.body || '',
|
||
imageUrl: event?.imageUrl || '',
|
||
seriesId: event?.seriesId ? String(event.seriesId) : '',
|
||
seriesOrder: event?.seriesOrder ?? 0,
|
||
concurrencyKey: event?.concurrencyKey || '',
|
||
graceSeconds: event?.graceSeconds ?? 900,
|
||
timezone: event?.timezone || 'UTC',
|
||
// Whether the public calendar announces it (Phase 14a). `?? true` rather
|
||
// than `|| true`: a definition an operator has deliberately unlisted sends
|
||
// `false`, and `||` would quietly re-list it on the next save.
|
||
listed: event?.listed ?? true,
|
||
// Whether the public calendar announces it (Phase 14a). `?? true` rather
|
||
// than `|| true`: a definition an operator has deliberately unlisted sends
|
||
// `false`, and `||` would quietly re-list it on the next save.
|
||
listed: event?.listed ?? true,
|
||
...scheduleFormFrom(spec.schedule),
|
||
phases: (spec.phases || []).map((p) => ({
|
||
key: p.key || '',
|
||
label: p.label || '',
|
||
advance: advanceFormFrom(p.advance),
|
||
steps: (p.steps || []).map((s) => ({
|
||
actionId: s.actionId || '',
|
||
label: s.label || '',
|
||
onFailure: s.onFailure || '',
|
||
dormant: Boolean(s.dormant),
|
||
actionVersion: s.actionVersion,
|
||
paramsText: JSON.stringify(s.params || {}, null, 2),
|
||
})),
|
||
})),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One phase's advance gate, as the spec shape — or null when it has none.
|
||
*
|
||
* **Whether the predicate is VALID is still the server's answer.** The builder
|
||
* coerces each literal to the type the trigger DECLARED — which is not a second
|
||
* validator but the thing that makes the first one's error useful: every value
|
||
* in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
|
||
* variable is refused by `engagement/conditions.js`, rightly, at which point the
|
||
* author is reading an error about JSON rather than about what they typed.
|
||
*
|
||
* A predicate the builder could not render round-trips through `whereText`
|
||
* unchanged. That is the point of keeping the text: the alternative to posting it
|
||
* back verbatim is dropping an author's tree because this screen could not draw
|
||
* it.
|
||
*/
|
||
export function advancePayload(advance, where, errors, variables = []) {
|
||
if (!advance || !advance.kind) return null
|
||
if (advance.kind === 'after') return { after: advance.after }
|
||
|
||
const out = { on: advance.on, count: Number(advance.count) || 1 }
|
||
if (advance.whereEditable === false) {
|
||
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
|
||
}
|
||
|
||
const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
|
||
if (built) out.where = built
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* The form, as a request body — or the list of everything wrong with it.
|
||
*
|
||
* Only the JSON parse is checked here, and only because a params box whose text
|
||
* is not JSON cannot be turned into a request at all. **Everything else is left
|
||
* to the server**: unknown params, wrong types, missing required ones, bad phase
|
||
* keys and duplicate keys all come back from `POST`/`PUT` as a list, and
|
||
* re-deciding any of them here would be a second validator drifting from the one
|
||
* that matters.
|
||
*
|
||
* `onFailure` is omitted when the author has not chosen one, so the server
|
||
* applies the action's risk-class default rather than being told a value the
|
||
* form invented.
|
||
*/
|
||
export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||
const errors = []
|
||
const phases = (form.phases || []).map((phase, pi) => {
|
||
const where = advancePayload(
|
||
phase.advance,
|
||
`Phase ${pi + 1} "${phase.label || phase.key}"`,
|
||
errors,
|
||
// The declared types the builder coerces against. A trigger nothing
|
||
// registers has none, and every literal then stays the string it was typed
|
||
// as — which is right: the gate is dormant, the server carries its `where`
|
||
// through unvalidated, and inventing types for it here would edit a
|
||
// predicate nobody can currently check.
|
||
triggersById.get(phase.advance?.on)?.variables || [],
|
||
)
|
||
return {
|
||
key: phase.key,
|
||
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) => {
|
||
const out = { actionId: step.actionId }
|
||
if (step.label) out.label = step.label
|
||
if (step.onFailure) out.onFailure = step.onFailure
|
||
const parsed = parseParams(step.paramsText)
|
||
if (parsed.error) {
|
||
errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`)
|
||
} else {
|
||
out.params = parsed.params
|
||
}
|
||
return out
|
||
}),
|
||
}
|
||
})
|
||
|
||
if (errors.length) return { ok: false, errors }
|
||
|
||
return {
|
||
ok: true,
|
||
payload: {
|
||
title: form.title,
|
||
summary: form.summary || null,
|
||
body: form.body || null,
|
||
imageUrl: form.imageUrl || null,
|
||
seriesId: form.seriesId ? Number(form.seriesId) : null,
|
||
seriesOrder: Number(form.seriesOrder) || 0,
|
||
concurrencyKey: form.concurrencyKey || null,
|
||
graceSeconds: Number(form.graceSeconds),
|
||
timezone: form.timezone,
|
||
listed: Boolean(form.listed),
|
||
listed: Boolean(form.listed),
|
||
spec: { schedule: scheduleFromForm(form), phases },
|
||
},
|
||
}
|
||
}
|
||
|
||
|
||
// ── The schedule (Phase 4) ─────────────────────────────────────────────────
|
||
//
|
||
// The four closed shapes of §E, mirrored so the form can render one and the
|
||
// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
|
||
// the deciders — this is what makes the form a form rather than a text box, and
|
||
// it is the whole reason the schedule is not a cron string: a closed set has a
|
||
// dropdown, and an operator can proofread a dropdown.
|
||
|
||
export const WEEKDAYS = [
|
||
'sunday',
|
||
'monday',
|
||
'tuesday',
|
||
'wednesday',
|
||
'thursday',
|
||
'friday',
|
||
'saturday',
|
||
]
|
||
|
||
export const SCHEDULE_KINDS = [
|
||
{ value: 'manual', label: 'Started by hand' },
|
||
{ value: 'once', label: 'Once, at a set time' },
|
||
{ value: 'weekly', label: 'Weekly, on chosen days' },
|
||
{ value: 'monthly', label: 'Monthly, on the nth weekday' },
|
||
]
|
||
|
||
// 1..4 and "last". There is no fifth: every month has a first through fourth of
|
||
// every weekday, and "last" is what a month with five Fridays makes different
|
||
// from "fourth" (org lead, 2026-09-02).
|
||
export const MONTHLY_NTHS = [
|
||
{ value: 1, label: 'First' },
|
||
{ value: 2, label: 'Second' },
|
||
{ value: 3, label: 'Third' },
|
||
{ value: 4, label: 'Fourth' },
|
||
{ value: -1, label: 'Last' },
|
||
]
|
||
|
||
const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
|
||
|
||
/**
|
||
* A schedule in words, in the event's own zone.
|
||
*
|
||
* The server says the same thing in `events/recurrence.js#describe`, and the two
|
||
* are allowed to differ on wording but not on meaning — this one is what an
|
||
* author reads while they are still typing, before anything has been saved.
|
||
*/
|
||
export function describeSchedule(schedule, timezone = 'UTC') {
|
||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||
const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
|
||
switch (schedule.kind) {
|
||
case 'manual':
|
||
return 'Started by hand — nothing happens until an admin presses Start'
|
||
case 'once': {
|
||
if (!schedule.at) return 'Once — no date chosen yet'
|
||
return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
|
||
}
|
||
case 'weekly': {
|
||
const days = (schedule.days || []).map(capitalise)
|
||
if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
|
||
const list =
|
||
days.length === 1
|
||
? days[0]
|
||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||
return `Every ${list} at ${schedule.time} (${timezone})`
|
||
}
|
||
case 'monthly': {
|
||
if (!nth || !schedule.weekday || !schedule.time) {
|
||
return 'Monthly — choose a week, a weekday and a time'
|
||
}
|
||
return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
|
||
}
|
||
default:
|
||
return 'No schedule'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The schedule half of the editor's working state.
|
||
*
|
||
* Every shape's fields are kept side by side rather than cleared when the kind
|
||
* changes, so an author who clicks Weekly, then Monthly, then back has not lost
|
||
* the days they picked. `scheduleFromForm` reads only the fields the chosen kind
|
||
* uses, which is what keeps the request body a clean single shape.
|
||
*/
|
||
export function scheduleFormFrom(schedule) {
|
||
const s = schedule || {}
|
||
return {
|
||
scheduleKind: s.kind || 'manual',
|
||
scheduleAt: s.kind === 'once' ? s.at || '' : '',
|
||
scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
|
||
scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
|
||
scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
|
||
scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
|
||
}
|
||
}
|
||
|
||
/** The schedule the form describes, as the spec object the server expects. */
|
||
export function scheduleFromForm(form) {
|
||
switch (form.scheduleKind) {
|
||
case 'once':
|
||
return { kind: 'once', at: form.scheduleAt }
|
||
case 'weekly':
|
||
return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
|
||
case 'monthly':
|
||
return {
|
||
kind: 'monthly',
|
||
nth: Number(form.scheduleNth),
|
||
weekday: form.scheduleWeekday,
|
||
time: form.scheduleTime,
|
||
}
|
||
default:
|
||
return { kind: 'manual' }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* What a calendar entry is, and therefore what may be done with it.
|
||
*
|
||
* A `run` is a row: it has a console and somebody can cancel it. A `projected`
|
||
* entry is arithmetic the runner has not reached yet — there is nothing to open
|
||
* and nothing to stop, and an operator who treats one as a booking has been
|
||
* misled by the UI rather than by the server.
|
||
*/
|
||
export const isProjected = (entry) => entry?.kind === 'projected'
|
||
|
||
/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
|
||
export function parseParams(text) {
|
||
const raw = (text || '').trim()
|
||
if (!raw) return { params: {} }
|
||
let value
|
||
try {
|
||
value = JSON.parse(raw)
|
||
} catch (err) {
|
||
return { error: `the params are not valid JSON (${err.message})` }
|
||
}
|
||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||
return { error: 'the params must be a JSON object' }
|
||
}
|
||
return { params: value }
|
||
}
|
||
|
||
// ── Step params, as a form (Phase 13) ─────────────────────────────
|
||
//
|
||
// §I: the step editor is *"the condition builder, exactly — core serves a
|
||
// catalog, the module declared the schema, core renders a form it does not
|
||
// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
|
||
// this, and everything the form needs was already in the catalog: a param's
|
||
// name, type, whether it is required, its description, its example, and the
|
||
// option source behind it.
|
||
//
|
||
// **The JSON stays as the storage and as the escape hatch, and both halves of
|
||
// that matter.** As storage, because `payloadFromForm` already builds a request
|
||
// out of it and a second representation would be two things to keep in step. As
|
||
// an escape hatch, because a form can only render what the declaration
|
||
// describes — and a step may legitimately hold something it does not.
|
||
//
|
||
// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
|
||
// the reason this reads as a port of it rather than as a new idea: a value the
|
||
// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
|
||
// `A and (B or C)` there and dropping an undeclared param here are the same
|
||
// mistake — a save that looks clean and means something else.
|
||
|
||
/** The two ways a step's params are edited. */
|
||
export const PARAM_FORM = 'form'
|
||
export const PARAM_JSON = 'json'
|
||
|
||
/**
|
||
* Can this step's params be rendered as a form without losing anything?
|
||
*
|
||
* `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
|
||
* Three things make one, and none of them is an error — each is a step that has
|
||
* to be edited as JSON:
|
||
*
|
||
* • **the action is dormant.** There is no declaration, so there are no fields.
|
||
* A form here would render nothing and look like a step with no params.
|
||
* • **a param the action does not declare.** The save refuses it by name, which
|
||
* is what the author needs to see — and a form that dropped it would post a
|
||
* step that saves cleanly having deleted something they typed.
|
||
* • **a value no single control can hold** — an object or an array against a
|
||
* scalar declaration.
|
||
*/
|
||
export function paramsRenderable(action, params) {
|
||
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
|
||
const declared = new Map((action.params || []).map((p) => [p.name, p]))
|
||
for (const [name, value] of Object.entries(params || {})) {
|
||
if (!declared.has(name)) {
|
||
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
|
||
}
|
||
if (value !== null && typeof value === 'object') {
|
||
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
|
||
}
|
||
}
|
||
return { ok: true }
|
||
}
|
||
|
||
/**
|
||
* Which mode should this step open in?
|
||
*
|
||
* The author's own choice wins whenever the form COULD render the step — an
|
||
* author who switched to JSON stays in JSON. What they cannot do is stay in a
|
||
* form that would lose something, so an unrenderable step is forced to JSON
|
||
* whatever the choice was, and the reason is returned so the screen can say it.
|
||
*/
|
||
export function paramsMode(step, action) {
|
||
const parsed = parseParams(step?.paramsText)
|
||
if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
|
||
const renderable = paramsRenderable(action, parsed.params)
|
||
if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
|
||
return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
|
||
}
|
||
|
||
/** One declared param's current value, as the control holds it. */
|
||
export function paramValue(step, name) {
|
||
const parsed = parseParams(step?.paramsText)
|
||
if (parsed.error) return undefined
|
||
return parsed.params[name]
|
||
}
|
||
|
||
/**
|
||
* Write one param, and give back the whole box.
|
||
*
|
||
* **An empty field REMOVES the key rather than posting an empty string**, and
|
||
* that is the server's own reading rather than a convenience: `checkParams`
|
||
* treats `undefined`, `null` and `''` alike — absent — so a required param left
|
||
* blank comes back as *"is required"*, which is the error the author needs,
|
||
* instead of as a type complaint about `""`.
|
||
*
|
||
* **A value that does not parse is passed through as typed.** `coerceLiteral` is
|
||
* the engagement builder's, unchanged, and its rule is the one that matters
|
||
* here too: half of `-` is not a number, and turning it into `NaN` or `0` while
|
||
* somebody is still typing would either post a value they never wrote or make
|
||
* the field impossible to type a negative into. The server's type check then
|
||
* names the param.
|
||
*
|
||
* Re-serialising the whole object rather than splicing text, for `pickParam`'s
|
||
* reason: a string edit that produced valid-looking JSON with a duplicate key
|
||
* would be a value the editor and the server read differently.
|
||
*/
|
||
export function setParam(step, name, raw, type) {
|
||
const parsed = parseParams(step?.paramsText)
|
||
if (parsed.error) return step?.paramsText || '{}'
|
||
const next = { ...parsed.params }
|
||
if (raw === '' || raw === undefined || raw === null) delete next[name]
|
||
else next[name] = coerceLiteral(type, raw)
|
||
return JSON.stringify(next, null, 2)
|
||
}
|
||
|
||
/**
|
||
* A stored `datetime` as a `datetime-local` input wants it, and back.
|
||
*
|
||
* The server normalises a datetime param to an ISO string (`conditions.js`
|
||
* `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
|
||
* slice is the whole conversion in one direction; in the other the input's own
|
||
* text is a moment `new Date()` parses, so it is posted as typed and the server
|
||
* does the normalising — one implementation of what a datetime is, and it is
|
||
* not this one.
|
||
*/
|
||
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
|
||
|
||
/**
|
||
* Everything the meter needs out of the form, and nothing else.
|
||
*
|
||
* The price route takes a spec, not a definition: no title, no schedule, no
|
||
* series. Sending the whole payload would put a document in front of a route
|
||
* that reads two fields of it — and would fail the moment the rest of the form
|
||
* is mid-edit, which is exactly when the meter is being read.
|
||
*
|
||
* A step whose params do not parse is sent with none rather than dropped, so a
|
||
* half-typed JSON box costs its own step's draw and not the phase's.
|
||
*/
|
||
export function priceBodyFrom(form) {
|
||
return {
|
||
phases: (form?.phases || []).map((phase) => ({
|
||
key: phase.key || null,
|
||
steps: (phase.steps || []).map((step) => ({
|
||
actionId: step.actionId || '',
|
||
params: parseParams(step.paramsText).params || {},
|
||
})),
|
||
})),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Is this plan worth pricing at all?
|
||
*
|
||
* A meter that fires on an empty form asks the server what nothing costs, on
|
||
* every keystroke of the title field. One step with an action chosen is the
|
||
* threshold, because that is the first moment there is an answer.
|
||
*/
|
||
export const worthPricing = (form) =>
|
||
(form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
|
||
|
||
// ── Rendering what happened ────────────────────────────────────────────────
|
||
|
||
const STATUS_WORDS = {
|
||
scheduled: 'Scheduled',
|
||
starting: 'Starting',
|
||
running: 'Running',
|
||
paused: 'Paused',
|
||
ending: 'Winding down',
|
||
completed: 'Completed',
|
||
cancelled: 'Cancelled',
|
||
failed: 'Failed',
|
||
missed: 'Missed',
|
||
}
|
||
|
||
export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown'
|
||
|
||
const KIND_WORDS = {
|
||
'run.created': 'Occurrence created',
|
||
'run.status': 'Run status',
|
||
'run.health': 'Health',
|
||
'run.blocked': 'Held off',
|
||
'phase.entered': 'Phase entered',
|
||
'phase.completed': 'Phase completed',
|
||
'step.status': 'Step',
|
||
'step.retry': 'Step retried',
|
||
'step.parked': 'Waiting on a human',
|
||
'phase.gate': 'Advance condition set',
|
||
'condition.evaluated': 'Condition evaluated',
|
||
'phase.advanced': 'Phase advanced',
|
||
// Phase 6. "Refused" reads differently from "Step" on purpose: an operator
|
||
// scanning a stopped run needs to see that nothing is broken.
|
||
'step.refused': 'Refused',
|
||
'run.budget': 'Caps',
|
||
'version.verified': 'Dry run passed',
|
||
note: 'Note',
|
||
}
|
||
|
||
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
|
||
|
||
/**
|
||
* One log line as a sentence.
|
||
*
|
||
* The `detail` of a human control carries `control` and `by`, which is what
|
||
* separates "the runner paused this because a world write failed" from "somebody
|
||
* pressed pause" — the two are the same transition and the console has to be
|
||
* able to tell them apart at a glance.
|
||
*/
|
||
export function describeLogLine(line) {
|
||
const d = line?.detail || {}
|
||
const by = d.by ? ' by staff' : ''
|
||
switch (line?.kind) {
|
||
case 'run.status':
|
||
return d.control
|
||
? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}`
|
||
: `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}`
|
||
case 'run.health':
|
||
return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}`
|
||
case 'run.blocked':
|
||
return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"`
|
||
case 'phase.entered':
|
||
return `Entered ${line.phase} (${d.steps ?? '?'} steps)`
|
||
case 'phase.completed':
|
||
return `${line.phase} finished`
|
||
case 'step.parked':
|
||
return `${d.action} is waiting on a human`
|
||
case 'step.retry':
|
||
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}`
|
||
case 'step.status':
|
||
return d.control
|
||
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
|
||
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
|
||
case 'run.created':
|
||
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`
|
||
// Phase 6. `step.refused` is its own kind rather than a `step.status` for a
|
||
// reason an operator feels at 2am: a refusal is not a failure, and the line
|
||
// has to say which deployment rule stopped it -- the answer to "not enabled"
|
||
// is a switch, and the answer to "over the cap" is a number.
|
||
case 'step.refused':
|
||
return `${d.action} refused: ${d.error}`
|
||
case 'run.budget':
|
||
return (d.dimensions || [])
|
||
.map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`)
|
||
.join(', ') || 'no caps apply to this run'
|
||
case 'version.verified':
|
||
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
|
||
default:
|
||
return logKindWord(line?.kind)
|
||
}
|
||
}
|