Files
website/client/src/lib/eventAuthoring.js
wtclaude e46842a28c
Some checks failed
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 8m57s
fix(events): carry a module's own account of a successful step
`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.

`module-uo` believed it, twice, since Phase 12b:

  * `uo.item.grant` answers `{ granted, missed, why }`
  * `uo.world.save` answers `{ started: true }`

The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.

Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.

## What this adds

`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.

**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.

  * objects only — the column is JSON and the console renders keys, so a bare
    string has nothing to render under, and core inventing a key would be core
    interpreting it after all;
  * 4KB of serialised JSON, dropped rather than truncated, because half a JSON
    object is not a JSON object;
  * unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
    runner would make the log INSERT throw, inside the one write documented
    never to;
  * re-parsed rather than passed through, so core holds no live reference into
    a module's object;
  * **anything wrong with it is dropped and logged, never a failure.** A step
    that did what it was asked must not be re-run because its module's
    commentary was malformed: that is a world write repeated for a log line.

The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.

## The renderer, which is half the fix

`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.

    uo.item.grant — granted: 8, missed: 4, why: bank full, offline
    uo.world.save — started: true

**`module-uo` needs no change**: the code it already shipped starts working.

MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:48:57 -05:00

818 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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',
// Phase 15. "Reported" rather than "Detail": the line is the module talking
// about its own verb, and every other word here names something core did.
'step.detail': 'Step reported',
note: 'Note',
}
// How deep and how long a module's own `detail` value is allowed to render.
// The dispatcher already caps the whole object at 4KB, so this is about a line
// staying a line — an operator scanning a run's log should not have one row
// wrap eight times because a module answered with an array of forty names.
const DETAIL_LIST_SHOWN = 5
const DETAIL_TEXT_MAX = 80
/**
* One value out of a module's `detail`, as text.
*
* **Core does not interpret these keys and neither does this.** A module wrote
* the object; the console shows it. That is the whole reason the renderer is
* generic rather than a switch — a switch would be core learning a module's
* vocabulary, which is the thing the module system exists to prevent.
*/
function detailValue(value) {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
return value.length > DETAIL_LIST_SHOWN
? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
: shown
}
if (typeof value === 'object') {
// A nested object is rendered by its keys rather than as JSON: an operator
// reading a log wants "granted: 8, missed: 4", not a brace.
return Object.entries(value)
.map(([k, v]) => `${k} ${detailValue(v)}`)
.join(', ')
}
const text = String(value)
return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}` : text
}
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`
// Phase 15. The one line whose body core did not compose: a module may answer
// a successful step with a `detail` object, and this renders whatever keys it
// put there. `action` is core's own and is pulled out to lead the sentence;
// everything after it is the module's.
//
// **Without this case the row would render as the literal string
// "step.detail"**, because the default below is a kind word and not a
// sentence — which would be the reporting channel existing and showing
// nothing, the exact failure it was built to fix.
case 'step.detail': {
const { action, ...rest } = d
const body = Object.entries(rest)
.map(([key, value]) => `${key}: ${detailValue(value)}`)
.join(', ')
return body ? `${action || 'A step'}${body}` : `${action || 'A step'} reported nothing`
}
default:
return logKindWord(line?.kind)
}
}